// ==UserScript== // @name ppixiv for Pixiv // @author ppixiv // @namespace 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 // @homepage https://github.com/ppixiv/ppixiv // @match https://*.pixiv.net/* // @run-at document-start // @icon https://ppixiv.org/ppixiv.png // @grant GM.xmlHttpRequest // @grant GM.setValue // @grant GM.getValue // @connect pixiv.net // @connect pximg.net // @connect self // // @comment Note: this doesn't actually give the script access everywhere. It just lets us request // @comment access to new domains at runtime. If you use a feature that needs access to another site, // @comment the script manager will ask for permission the first time. // @connect * // // @version 231 // ==/UserScript== (function() { let env = {}; env.version = "231"; env.resources = {}; env.modules = { "/vview/app.js": loadBlob("application/javascript", `import InstallPolyfills from '/vview/misc/polyfills.js'; import WhatsNew from '/vview/widgets/whats-new.js'; import SavedSearchTags from '/vview/misc/saved-search-tags.js'; import TagTranslations from '/vview/misc/tag-translations.js'; import ScreenIllust from '/vview/screen-illust/screen-illust.js'; import ScreenSearch from '/vview/screen-search/screen-search.js'; import ContextMenu from '/vview/context-menu.js'; import Muting from '/vview/misc/muting.js'; import SendImage, { LinkThisTabPopup, SendHerePopup } from '/vview/misc/send-image.js'; import Settings from '/vview/misc/settings.js'; import { SlideshowStagingDialog } from '/vview/widgets/settings-widgets.js'; import DialogWidget from '/vview/widgets/dialog.js'; import MessageWidget from '/vview/widgets/message-widget.js'; import MediaCache from '/vview/misc/media-cache.js'; import UserCache from '/vview/misc/user-cache.js'; import ExtraCache from '/vview/misc/extra-cache.js'; import { helpers, PointerEventMovement } from '/vview/misc/helpers.js'; import * as Recaptcha from '/vview/util/recaptcha.js'; import ExtraImageData from '/vview/misc/extra-image-data.js'; import GuessImageURL from '/vview/misc/guess-image-url.js'; import ImageTranslations from '/vview/misc/image-translation.js'; import PointerListener from '/vview/actors/pointer-listener.js'; import { getUrlForMediaId } from '/vview/misc/media-ids.js' import VirtualHistory from '/vview/util/virtual-history.js'; import SiteNative from '/vview/sites/native/site-native.js'; import SitePixiv from '/vview/sites/pixiv/site-pixiv.js'; import * as Hooks from '/vview/util/hooks.js'; /\x2f This is the main top-level app controller. export default class App { constructor({showLoggedOutMessage}) { ppixiv.app = this; this.showLoggedOutMessage = showLoggedOutMessage; this.setup(); } /\x2f This is where the actual UI starts. async setup() { console.log(\`\${ppixiv.native? "vview":"ppixiv"} controller setup\`); /\x2f Hide the bright white document until we've loaded our stylesheet. if(!ppixiv.native) this._temporarilyHideDocument(); /\x2f Wait for DOMContentLoaded. await helpers.other.waitForContentLoaded(); /\x2f Init hooks if any. await Hooks?.init(this); /\x2f Install polyfills. InstallPolyfills(); /\x2f Create singletons. ppixiv.phistory = new VirtualHistory({ permanent: ppixiv.mobile }); ppixiv.settings = new Settings(); ppixiv.mediaCache = new MediaCache(); ppixiv.userCache = new UserCache(); ppixiv.extraImageData = new ExtraImageData(); ppixiv.extraCache = new ExtraCache(); ppixiv.sendImage = new SendImage(); ppixiv.tagTranslations = new TagTranslations(); ppixiv.guessImageUrl = new GuessImageURL(); ppixiv.muting = new Muting(); ppixiv.imageTranslations = new ImageTranslations(); /\x2f Set up the PointerListener singleton. PointerListener.installGlobalHandler(); /\x2f Set up iOS movementX/movementY handling. new PointerEventMovement(); /\x2f Window focus: let refreshFocus = () => { helpers.html.setClass(document.body, "focused", document.hasFocus()); }; window.addEventListener("focus", refreshFocus); window.addEventListener("blur", refreshFocus); refreshFocus(); /\x2f Don't restore the scroll position. We handle this ourself. window.history.scrollRestoration = "manual"; /\x2f not phistory if(ppixiv.mobile) { /\x2f On mobile, disable long press opening the context menu and starting drags. window.addEventListener("contextmenu", (e) => { e.preventDefault(); }); window.addEventListener("dragstart", (e) => { e.preventDefault(); }); helpers.forceTargetBlank(); } /\x2f Create the site singleton for the site we're on. if(ppixiv.site == null) { if(ppixiv.native) ppixiv.site = new SiteNative(); else ppixiv.site = new SitePixiv(); } if(!await ppixiv.site.init()) return; /\x2f See if we want to adjust the initial URL. await ppixiv.site.setInitialUrl(); window.addEventListener("click", this._windowClickCapture); window.addEventListener("popstate", this._windowRedirectPopstate, true); window.addEventListener("pp:popstate", this._windowPopstate); window.addEventListener("keyup", this._redirectEventToScreen, true); window.addEventListener("keydown", this._redirectEventToScreen, true); window.addEventListener("keypress", this._redirectEventToScreen, true); window.addEventListener("keydown", this._windowKeydown); /\x2f Dark Reader is terrible rubbish. Don't use it. let disableDarkReader = document.realCreateElement("meta"); disableDarkReader.name = "darkreader-lock"; document.head.appendChild(disableDarkReader); /\x2f Load image resources into blobs. await this.loadResourceBlobs(); /\x2f Add the blobs for binary resources as CSS variables. helpers.html.addStyle("image-styles", \` html { --dark-noise: url("\${ppixiv.resources['resources/noise.png']}"); } \`); /\x2f Load our icon font. var() doesn't work for font-face src, so we have to do /\x2f this manually. helpers.html.addStyle("ppixiv-font", \` @font-face { font-family: 'ppixiv'; src: url(\${ppixiv.resources['resources/ppixiv.woff']}) format('woff'); font-weight: normal; font-style: normal; font-display: block; } \`); /\x2f Add the main stylesheet. let mainStylesheet = ppixiv.resources['resources/main.css']; if(mainStylesheet == null) throw new Error("resources/main.css missing"); document.head.appendChild(helpers.html.createStyle(mainStylesheet, { id: "main" })); /\x2f If we're running natively, index.html included an initial stylesheet to set the background /\x2f color. Remove it now that we have our real stylesheet. let initialStylesheet = document.querySelector("#initial-style"); if(initialStylesheet) initialStylesheet.remove(); /\x2f If we don't have a viewport tag, add it. This makes Safari work more sanely when /\x2f in landscape. If we're native, this is already set, and we want to use the existing /\x2f one or Safari doesn't always set the frame correctly. if(ppixiv.ios && document.querySelector("meta[name='viewport']") == null) { /\x2f Set the viewport. let meta = document.createElement("meta"); meta.setAttribute("name", "viewport"); meta.setAttribute("content", "viewport-fit=cover, initial-scale=1, user-scalable=no"); document.head.appendChild(meta); } /\x2f Add to tell iOS how to color the UI. If "Allow Website Tinting" is /\x2f enabled and the navigation bar is hidden, Safari tries to guess the UI color and sometimes /\x2f randomly gets it wrong. We always want black. { let meta = document.createElement("meta"); meta.setAttribute("name", "theme-color"); meta.setAttribute("content", "#000"); document.head.appendChild(meta); } /\x2f Now that we've cleared the document and added our style so our background color is /\x2f correct, we can unhide the document. this._undoTemporarilyHideDocument(); /\x2f Device properties. Do this after cleaning up the document, since it can create nodes. this._setDeviceProperties(); ppixiv.settings.addEventListener("display_mode", this._setDeviceProperties); window.addEventListener("orientationchange", this._setDeviceProperties); new ResizeObserver(this._setDeviceProperties).observe(document.documentElement); /\x2f Message popups: ppixiv.message = new MessageWidget({container: document.body}); /\x2f Load Recaptcha if it's required by Pixiv. Recaptcha.load(); /\x2f Create the shared title. This is set by helpers.setPageTitle. if(document.querySelector("title") == null) document.head.appendChild(document.createElement("title")); /\x2f Create the shared page icon. This is set by setPageIcon. let documentIcon = document.head.appendChild(document.createElement("link")); documentIcon.setAttribute("rel", "icon"); /\x2f See if this is a slideshow staging window. If it is, show the instruction dialog /\x2f and don't load screens. if(window.opener?.slideshowStagingDialog == window) { new SlideshowStagingDialog(); return; } this.addClicksToSearchHistory(document.body); /\x2f Create the popup menu. if(!ppixiv.mobile) this._contextMenu = new ContextMenu({container: document.body}); LinkThisTabPopup.setup(); SendHerePopup.setup(); /\x2f Set the whats-new-updated class. WhatsNew.handleLastViewedVersion(); /\x2f Create the screens. this._screenSearch = new ScreenSearch({ container: document.body, visible: false }); this._screenIllust = new ScreenIllust({ container: document.body, visible: false }); this._currentScreen = null; /\x2f Create the data source for this page. this.setCurrentDataSource({ cause: "initialization" }); }; /\x2f Pixiv puts listeners on popstate which we can't always remove, and can get confused and reload /\x2f the page when it sees navigations that don't work. /\x2f /\x2f Try to work around this by capturing popstate events and stopping the event, then redirecting /\x2f them to our own pp:popstate event, which is what we listen for. This prevents anything other than /\x2f a capturing listener from seeing popstate. _windowRedirectPopstate = (e) => { e.stopImmediatePropagation(); let e2 = new Event("pp:popstate"); e.target.dispatchEvent(e2); } _windowPopstate = (e) => { /\x2f Set the current data source and state. this.setCurrentDataSource({ cause: e.navigationCause || "history", scrollToTop: e.scrollToTop, }); } _setDeviceProperties = () => { let insets = helpers.html.getSafeAreaInsets(); helpers.html.setClass(document.documentElement, "mobile", ppixiv.mobile); let firefox = navigator.userAgent.indexOf("Gecko/") != -1 || navigator.userAgent.indexOf("Firefox/") != -1; helpers.html.setClass(document.documentElement, "firefox", firefox); helpers.html.setClass(document.documentElement, "macos", navigator.userAgent.indexOf("Macintosh") != -1); /\x2f at least Safari or Chrome helpers.html.setClass(document.documentElement, "ios", ppixiv.ios); helpers.html.setClass(document.documentElement, "android", ppixiv.android); helpers.html.setClass(document.documentElement, "phone", helpers.other.isPhone()); document.documentElement.dataset.orientation = window.orientation ?? "0"; helpers.html.setDataSet(document.documentElement.dataset, "hasBottomInset", insets.bottom > 0); /\x2f Set has-overlaid-scrollbars if we think the browser has overlay scrollbars. This /\x2f is used to figure out if scrollbar-gutter: both-edges adds padding or if we need to /\x2f do it ourself. This used to be easy using overflow: overlay, but Google in their infinite /\x2f lack of wisdom removed that, leaving no way of enabling overlay scrollbars and no way /\x2f of knowing if scrollbar-gutter adds padding or not other than a manual test like this. /\x2f They didn't think this through at all. let testOverlayScrollbars = document.realCreateElement("div"); testOverlayScrollbars.classList.add("overlay-scrollbar-tester"); testOverlayScrollbars.style.position = "absolute"; testOverlayScrollbars.style.visibility = "hidden"; testOverlayScrollbars.style.scrollbarGutter = "stable both-edges"; testOverlayScrollbars.style.overflowY = "auto"; testOverlayScrollbars.style.width = "100px"; testOverlayScrollbars.style.height = "100px"; document.body.appendChild(testOverlayScrollbars); let hasOverlayScrollbars = testOverlayScrollbars.offsetWidth == testOverlayScrollbars.scrollWidth; helpers.html.setClass(document.documentElement, "has-overlay-scrollbars", hasOverlayScrollbars); testOverlayScrollbars.remove(); /\x2f Set the fullscreen mode. See the device styling rules in main.scss for more /\x2f info. let displayMode = ppixiv.settings.get("display_mode", "auto"); if(["auto", "normal", "notch", "safe"].indexOf(displayMode) == -1) displayMode = "auto"; if(displayMode == "auto") displayMode = this.autoDisplayMode; document.documentElement.dataset.displayMode = displayMode; } /\x2f Return the display mode that will be used if "auto" is selected. /\x2f /\x2f Try to figure out if we're on a device with a notch. There's no way to query this, /\x2f and if we're on an iPhone we can't even directly query which model it is, so we have /\x2f to guess. For iPhones, assume that we have a notch if we have a bottom inset, since /\x2f all current iPhones with a notch also have a bottom inset for the ugly pointless white /\x2f line at the bottom of the screen. /\x2f /\x2f We'd like to default to notch mode if we're in a current iPhone in top navigation bar /\x2f mode, but that's hard to detect. get autoDisplayMode() { let insets = helpers.html.getSafeAreaInsets(); if(ppixiv.ios && navigator.platform.indexOf('iPhone') != -1) { if(insets.bottom > 0) return "notch"; /\x2f Work around an iOS bug: when running in Safari (not as a PWA) in landscape with the /\x2f toolbar hidden, the content always overlaps the navigation line, but it doesn't report /\x2f it in the safe area. This causes us to not detect notch mode. It does report the notch /\x2f safe area on the left or right, and incorrectly reports a matching safe area on the right /\x2f (there's nothing there to need a safe area), so check for this as a special case. if(!navigator.standalone && (insets.left > 20 && insets.right == insets.left)) return "notch"; } return "normal"; } get currentDataSource() { return this._dataSource; } /\x2f Create a data source for the current URL and activate it. /\x2f /\x2f This is called on startup, and in onpopstate where we might be changing data sources. async setCurrentDataSource(args) { /\x2f If we're called again before a previous call finishes, let the previous call /\x2f finish first. let token = this._setCurrentDataSourceToken = new Object(); /\x2f Wait for any other running setCurrentDataSource calls to finish. while(this._setCurrentDataSourcePromise != null) await this._setCurrentDataSourcePromise; /\x2f If token doesn't match anymore, another call was made, so ignore this call. if(token !== this._setCurrentDataSourceToken) return; let promise = this._setCurrentDataSourcePromise = this._setCurrentDataSource(args); promise.finally(() => { if(promise == this._setCurrentDataSourcePromise) this._setCurrentDataSourcePromise = null; }); return promise; } async _setCurrentDataSource({cause, refresh, scrollToTop, startAtBeginning}) { let args = helpers.args.location; /\x2f Get the data source for the current URL. If refresh is true, force a new data /\x2f source to be created instead of reusing an existing one. let dataSource = ppixiv.site.createDataSourceForUrl(ppixiv.plocation, { force: refresh, startAtBeginning, }); /\x2f Figure out which screen to display. let newScreenName = args.hash.get("view") ?? dataSource.defaultScreen; console.assert(newScreenName == "illust" || newScreenName == "search", newScreenName); let newScreen = newScreenName == "illust"? this._screenIllust:this._screenSearch; /\x2f Remember what we were displaying before we start changing things. let oldScreen = this._currentScreen; /\x2f The media ID we're displaying if we're going to ScreenIllust. If this is slideshow=first, /\x2f this will be null. let mediaId = null; if(newScreen.screenType == "illust") mediaId = dataSource.getUrlMediaId(args); /\x2f If we're going back to the start of the search, update the page URL to put it back /\x2f at the start too, and remove any saved scroll position. if(startAtBeginning) { delete args.state.scroll; dataSource.setStartPage(args, 1); helpers.navigate(args, { addToHistory: false, cause: "refresh-data-source", sendPopstate: false }); } /\x2f See if there's a media ID we want the new screen to display. If the data source /\x2f is able to scan its results in advance, it can set the start page so it includes /\x2f this ID, so the search will start naturally around it. ScreenSearch will display /\x2f images around it and navigating ScreenIllust will move around it. (If we have /\x2f an image but the data source can't start there, we'll fall back on putting this /\x2f image at the beginning.) /\x2f /\x2f If scrollToTop (data-scroll-to-top) is set, skip this since we want to return to /\x2f the top of the search. let targetMediaId = null; if(!scrollToTop) { if(newScreen.screenType == "search") { if(oldScreen?.screenType == "illust") { /\x2f When going from illust -> search, target the image that was being displayed, so /\x2f we can scroll to it. targetMediaId = oldScreen?.displayedMediaId; } else { /\x2f Otherwise, if ScreenSearch has saved the scroll position, try to include /\x2f the image it wants to scroll to. targetMediaId = newScreen.getTargetMediaId(args); } } else if(newScreen.screenType == "illust") { /\x2f Use the image we'll be displaying. targetMediaId = mediaId; } } /\x2f Init the data source. await dataSource.init({targetMediaId}); /\x2f If slideshow=first, this is starting a slideshow at whichever image is first in the /\x2f results. Set the media ID now that the data source is initialized and can look up /\x2f pages. if(newScreen.screenType == "illust" && args.hash.get("slideshow") == "first") { mediaId = await this.getMediaIdForSlideshow({ dataSource }); if(mediaId == null) { /\x2f The search for this slideshow didn't return any images. This can happen /\x2f from a saved slideshow link if the user's login creds are gone. We can't /\x2f show the illust view without an illust, so navigate to the search equivalent /\x2f so the UI works to let the user log back in. ppixiv.message.show("Couldn't find a slideshow image to view"); let args = helpers.args.location; args.hash.set("view", "search"); args.hash.delete("slideshow"); helpers.navigate(args, { addToHistory: true, cause: "slideshow-failed" }); return; } console.log("Starting slideshow at:", mediaId); args.hash.set("slideshow", "1"); dataSource.setUrlMediaId(mediaId, args); helpers.navigate(args, { addToHistory: false, cause: "start-slideshow", sendPopstate: false }); } /\x2f If the data source is changing, set it up. if(this._dataSource != dataSource) { if(this._dataSource != null) { /\x2f Shut down the old data source. this._dataSource.shutdown(); /\x2f If the old data source was transient, discard it. if(this._dataSource.transient) ppixiv.site.discardDataSource(this._dataSource); } this._dataSource = dataSource; if(this._dataSource != null) this._dataSource.startup(); } /\x2f If we're entering ScreenSearch, ignore clicks for a while. See _windowClickCapture. if(newScreen.screenType == "search") this._ignoreClicksUntil = Date.now() + 100; console.log(\`Showing screen: \${newScreen.screenType}, data source: \${this._dataSource.name}, cause: \${cause}, media ID: \${mediaId ?? "(none)"}, scroll to: \${targetMediaId}\`); this._currentScreen = newScreen; if(newScreen != oldScreen) { /\x2f Let the screens know whether they're current. Screens don't use visible /\x2f directly (visibility is controlled by animations instead), but this lets /\x2f visibleRecursively know if the hierarchy is visible. if(oldScreen) oldScreen.visible = false; if(newScreen) newScreen.visible = true; let e = new Event("screenchanged"); e.newScreen = newScreen.screenType; window.dispatchEvent(e); } /\x2f The data source is set separately from activation because scrollSearchToMediaId can set /\x2f the screen's data source before it's visible for transitions. newScreen.setDataSource(dataSource, { targetMediaId }); if(this._contextMenu) { this._contextMenu.setDataSource(this._dataSource); /\x2f If we're showing a media ID, use it. Otherwise, see if the screen is /\x2f showing one. let displayedMediaId = mediaId; displayedMediaId ??= newScreen.displayedMediaId; this._contextMenu.setMediaId(displayedMediaId); } /\x2f Tell translations the media ID we're viewing. ppixiv.imageTranslations.setDisplayedMediaId(mediaId); /\x2f Restore state from history if this is an initial load (which may be /\x2f restoring a tab), for browser forward/back, or if we're exiting from /\x2f quick view (which is like browser back). This causes the pan/zoom state /\x2f to be restored. let restoreHistory = cause == "initialization" || cause == "history" || cause == "leaving-virtual"; /\x2f Activate the new screen. await newScreen.activate({ mediaId, cause, restoreHistory, }); /\x2f Deactivate the old screen. if(oldScreen != null && oldScreen != newScreen) oldScreen.deactivate(); } getRectForMediaId(mediaId) { return this._screenSearch.getRectForMediaId(mediaId); } /\x2f Return the URL to display a media ID. getMediaURL(mediaId, {screen="illust", tempView=false}={}) { console.assert(mediaId != null, "Invalid illust_id", mediaId); let args = helpers.args.location; /\x2f Check if this is a local ID. if(helpers.mediaId.isLocal(mediaId)) { if(helpers.mediaId.parse(mediaId).type == "folder") { /\x2f If we're told to show a folder: ID, always go to the search page, not the illust page. screen = "search"; /\x2f When navigating to a subdirectory, discard the search filters. If we're viewing bookmarks /\x2f and we click a bookmarked folder, we want to see contents of the bookmarked folder, not /\x2f bookmarks within the bookmark. args = new helpers.args("/"); } } /\x2f If this is a user ID, just go to the user page. let { type, id } = helpers.mediaId.parse(mediaId); if(type == "user") return new helpers.args(\`/users/\${id}/artworks#ppixiv\`); let oldMediaId = this._dataSource.getUrlMediaId(args); /\x2f Update the URL to display this mediaId. This stays on the same data source, /\x2f so displaying an illust won't cause a search to be made in the background or /\x2f have other side-effects. this._setActiveScreenInUrl(args, screen); this._dataSource.setUrlMediaId(mediaId, args); if(tempView) { args.hash.set("virtual", "1"); args.hash.set("temp-view", "1"); } else { args.hash.delete("virtual"); args.hash.delete("temp-view"); } /\x2f If we were viewing a muted image and we're navigating away from it, remove view-muted so /\x2f we're muting images again. Don't do this if we're navigating between pages of the same post. let [illustId] = helpers.mediaId.toIllustIdAndPage(mediaId); let [oldIllustId] = helpers.mediaId.toIllustIdAndPage(oldMediaId); if(illustId != oldIllustId) args.hash.delete("view-muted"); return args; } /\x2f Show an illustration by ID. /\x2f /\x2f This actually just sets the history URL. We'll do the rest of the work in popstate. showMediaId(mediaId, {addToHistory=false, ...options}={}) { let args = this.getMediaURL(mediaId, options); helpers.navigate(args, { addToHistory }); } /\x2f Return the displayed screen instance or name. getDisplayedScreen() { return this._currentScreen?.screenType; } /\x2f If we're viewing an illustration, return its media ID. /\x2f /\x2f Most of the time, we tell the screen and widgets what to display. This is used for /\x2f edge cases where we just need to know the current ID. get displayedMediaId() { if(this._currentScreen?.screenType != "illust" || this._dataSource == null) return null; let args = helpers.args.location; return this._dataSource.getUrlMediaId(args); } _setActiveScreenInUrl(args, screen) { /\x2f If this is the default, just remove it. if(screen == this._dataSource.defaultScreen) args.hash.delete("view"); else args.hash.set("view", screen); /\x2f If we're going to the search screen, remove the page and illust ID. if(screen == "search") { args.hash.delete("page"); args.hash.delete("illust_id"); } /\x2f If we're going somewhere other than illust, remove zoom state, so /\x2f it's not still around the next time we view an image. if(screen != "illust") delete args.state.zoom; } /\x2f This is called by ScreenIllust when it wants ScreenSearch to try to display a /\x2f media ID in a data source, so it's ready for a transition to start. This only /\x2f has an effect if search isn't already active. scrollSearchToMediaId(dataSource, mediaId) { if(this._currentScreen.screenType == "search") return; this._screenSearch.setDataSource(dataSource, { targetMediaId: mediaId }); } /\x2f Navigate to args. /\x2f /\x2f This is called when the illust view wants to pop itself and return to a search /\x2f instead of pushing a search in front of it. If args is the previous history state, /\x2f we'll just go back to it, otherwise we'll replace the current state. This is only /\x2f used when permanent navigation is enabled, otherwise we can't see what the previous /\x2f state was. navigateFromIllustToSearch(args) { /\x2f If phistory.permanent isn't active, just navigate normally. This is only used /\x2f on mobile. if(!ppixiv.phistory.permanent) { helpers.navigate(args); return; } /\x2f Compare the canonical URLs, so we'll return to the entry in history even if the search /\x2f page doesn't match. let previousUrl = ppixiv.phistory.previousStateUrl; let canonicalPreviousUrl = previousUrl? helpers.getCanonicalUrl(previousUrl):null; let canonicalNewUrl = helpers.getCanonicalUrl(args.url); let sameUrl = helpers.areUrlsEquivalent(canonicalPreviousUrl, canonicalNewUrl); if(sameUrl) { console.log("Navigated search is last in history, going there instead"); ppixiv.phistory.back(); } else { helpers.navigate(args, { addToHistory: false }); } } /\x2f This captures clicks at the window level, allowing us to override them. /\x2f /\x2f When the user left clicks on a link that also goes into one of our screens, /\x2f rather than loading a new page, we just set up a new data source, so we /\x2f don't have to do a full navigation. /\x2f /\x2f This only affects left clicks (middle clicks into a new tab still behave /\x2f normally). /\x2f /\x2f This also handles redirecting navigation to ppixiv.VirtualHistory on iOS. _windowClickCapture = (e) => { /\x2f Only intercept regular left clicks. if(e.button != 0 || e.metaKey || e.ctrlKey || e.altKey) return; if(!(e.target instanceof Element)) return; /\x2f We're taking the place of the default behavior. If somebody called preventDefault(), /\x2f stop. if(e.defaultPrevented) return; /\x2f Look up from the target for a link. let a = e.target.closest("A"); if(a == null || !a.hasAttribute("href")) return; /\x2f If this isn't a #ppixiv URL, let it run normally. let url = new URL(a.href, document.href); if(!helpers.args.isPPixivUrl(url)) return; /\x2f Stop all handling for this link. e.preventDefault(); e.stopImmediatePropagation(); /\x2f Work around an iOS bug. After dragging out of an image, Safari sometimes sends a click /\x2f to the thumbnail that appears underneath the drag, even though it wasn't the element that /\x2f received the pointer events. Stopping the pointerup event doesn't prevent this. This /\x2f causes us to sometimes navigate into a random image after transitioning back out into /\x2f search results. Prevent this by ignoring clicks briefly after changing to the search /\x2f screen. if(ppixiv.ios && this._ignoreClicksUntil != null && Date.now() < this._ignoreClicksUntil) { console.log(\`Ignoring click while activating screen: \${this._ignoreClicksUntil - Date.now()}\`); return; } /\x2f If this is a link to an image (usually /artworks/#), navigate to the image directly. /\x2f This way, we actually use the URL for the illustration on this data source instead of /\x2f switching to /artworks. This also applies to local image IDs, but not folders. url = helpers.pixiv.getUrlWithoutLanguage(url); let { mediaId } = this.getMediaIdAtElement(a); if(mediaId) { let args = new helpers.args(a.href); let screen = args.hash.has("view")? args.hash.get("view"):"illust"; this.showMediaId(mediaId, { screen: screen, addToHistory: true }); return; } helpers.navigate(url, { /\x2f If a link has the data-scroll-to-top attribute, remember that we want to scroll /\x2f to the top of the search instead of restoring the position. scrollToTop: a.dataset.scrollToTop /\x2f data-scroll-to-top }); } /\x2f Redirect keyboard events that didn't go into the active screen. _redirectEventToScreen = (e) => { let screen = this._currentScreen; if(screen == null) return; /\x2f If a dialog is open, leave inputs alone. if(DialogWidget.activeDialogs.length > 0) return; /\x2f If the event is going to an element inside the screen already, just let it continue. if(helpers.html.isAbove(screen.root, e.target)) return; /\x2f If the keyboard input didn't go to an element inside the screen, redirect /\x2f it to the screen. let e2 = new e.constructor(e.type, e); if(!screen.root.dispatchEvent(e2)) { e.preventDefault(); e.stopImmediatePropagation(); return; } } _windowKeydown = (e) => { /\x2f Ignore keypresses if we haven't set up the screen yet. let screen = this._currentScreen; if(screen == null) return; /\x2f If a dialog is open, leave inputs alone and don't process hotkeys. if(DialogWidget.activeDialogs.length > 0) return; /\x2f Let the screen handle the input. screen.handleKeydown(e); } /\x2f Return the media ID under element. getMediaIdAtElement(element) { if(element == null) return { }; /\x2f Illustration search results have both the media ID and the user ID on it. let mediaElement = element.closest("[data-media-id]"); if(mediaElement) return { mediaId: mediaElement.dataset.mediaId }; let userElement = element.closest("[data-user-id]"); if(userElement) return { mediaId: \`user:\${userElement.dataset.userId}\` }; return { }; } /\x2f Load binary resources into blobs, so we don't copy images into every /\x2f place they're used. async loadResourceBlobs() { /\x2f ppixiv.resources maps from resource names to URLs. Fetch text resources like /\x2f HTML and SVG, and leave binaries as URLs. Unless we're running natively or /\x2f in debug, these are all blob URLs. let fetches = []; for(let [path, url] of Object.entries(ppixiv.resources)) { let filename = (new URL(path, ppixiv.plocation)).pathname; let binary = filename.endsWith(".png") || filename.endsWith(".woff"); if(binary) continue; fetches[path] = realFetch(url); } await Promise.all(Object.values(fetches)); for(let path of Object.keys(ppixiv.resources)) { if(fetches[path] == null) continue; let data = await fetches[path]; let text = await data.text(); ppixiv.resources[path] = text; } } _temporarilyHideDocument() { if(document.documentElement == null) return; document.documentElement.style.filter = "brightness(0)"; document.documentElement.style.backgroundColor = "#000"; } _undoTemporarilyHideDocument() { document.documentElement.style.filter = ""; document.documentElement.style.backgroundColor = ""; } /\x2f When viewing an image, toggle the slideshow on or off. toggleSlideshow() { /\x2f Add or remove slideshow=1 from the hash. if(this._currentScreen.screenType != "illust") return; let args = helpers.args.location; let enabled = args.hash.get("slideshow") == "1"; /\x2f not hold if(enabled) args.hash.delete("slideshow"); else args.hash.set("slideshow", "1"); helpers.navigate(args, { addToHistory: false, cause: "toggle slideshow" }); } get slideshowMode() { return helpers.args.location.hash.get("slideshow"); } loopSlideshow() { if(this._currentScreen.screenType != "illust") return; let args = helpers.args.location; let enabled = args.hash.get("slideshow") == "loop"; if(enabled) args.hash.delete("slideshow"); else args.hash.set("slideshow", "loop"); helpers.navigate(args, { addToHistory: false, cause: "loop" }); } /\x2f Return the URL args to display a slideshow from the current page. /\x2f /\x2f This is usually used from a search, and displays a slideshow for the current /\x2f search. It can also be called while on an illust from SlideshowStagingDialog. get slideshowURL() { let args = this._dataSource.args; args.hash.set("slideshow", "first"); args.hash.set("view", "illust"); return args; } /\x2f When loading slideshowURL, try to find a starting image for the slideshow. async getMediaIdForSlideshow({dataSource}) { /\x2f Load the initial page so we can look for an ID. await dataSource.loadPage(dataSource.initialPage); let mediaId = dataSource.idList.getFirstId(); if(mediaId == null) return null; /\x2f The ID must be illust or file. Make sure we don't set it to a folder. let { type } = helpers.mediaId.parse(mediaId); if(type != "file" && type != "illust") { console.log("Can't display ID as slideshow:", mediaId); return null; } return mediaId; } /\x2f Watch for clicks on links inside node. If a search link is clicked, add it to the /\x2f recent search list. addClicksToSearchHistory(node) { node.addEventListener("click", function(e) { if(e.defaultPrevented) return; if(e.target.tagName != "A" || !e.target.hasAttribute("href")) return; /\x2f Only look at "/tags/TAG" URLs. let url = new URL(e.target.href); url = helpers.pixiv.getUrlWithoutLanguage(url); let parts = url.pathname.split("/"); let firstPart = parts[1]; if(firstPart != "tags") return; let tag = helpers.pixiv.getSearchTagsFromUrl(url); /\x2f console.log("Adding to tag search history:", tag); SavedSearchTags.add(tag); }); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/app.js `), "/vview/context-menu.js": loadBlob("application/javascript", `/\x2f A global right-click popup menu. /\x2f /\x2f This is only active when right clicking over items with the context-menu-target /\x2f class. /\x2f /\x2f Not all items are available all the time. This is a singleton class, so it's easy /\x2f for different parts of the UI to tell us when they're active. /\x2f /\x2f This also handles mousewheel zooming. import Widget from '/vview/widgets/widget.js'; import { BookmarkButtonWidget, LikeButtonWidget } from '/vview/widgets/illust-widgets.js'; import { HideMouseCursorOnIdle } from '/vview/misc/hide-mouse-cursor-on-idle.js'; import { BookmarkTagDropdownOpener } from '/vview/widgets/bookmark-tag-list.js'; import { AvatarWidget, GetUserIdFromMediaId } from '/vview/widgets/user-widgets.js'; import MoreOptionsDropdown from '/vview/widgets/more-options-dropdown.js'; import FixChromeClicks from '/vview/misc/fix-chrome-clicks.js'; import { ViewInExplorerWidget } from '/vview/widgets/local-widgets.js'; import { IllustWidget } from '/vview/widgets/illust-widgets.js'; import PointerListener from '/vview/actors/pointer-listener.js'; import { DropdownBoxOpener } from '/vview/widgets/dropdown.js'; import ClickOutsideListener from '/vview/widgets/click-outside-listener.js'; import Actions from '/vview/misc/actions.js'; import { getUrlForMediaId } from '/vview/misc/media-ids.js' import LocalAPI from '/vview/misc/local-api.js'; import { GetMediaInfo } from '/vview/widgets/illust-widgets.js'; import { helpers, ClassFlags, KeyListener, OpenWidgets } from '/vview/misc/helpers.js'; export default class ContextMenu extends Widget { /\x2f Names for buttons, for storing in this._buttonsDown. buttons = ["lmb", "rmb", "mmb"]; constructor({...options}) { super({...options, template: \` \`}); this.visible = false; this.hide = this.hide.bind(this); this._currentViewer = null; this._mediaId = null; /\x2f Whether the left and right mouse buttons are pressed: this._buttonsDown = {}; /\x2f This UI isn't used on mobile, but we're still created so other code doesn't need /\x2f to check if we exist. if(ppixiv.mobile) return; this.getUserIdFromMediaId = new GetUserIdFromMediaId({ parent: this, onrefresh: ({userId}) => { this._cachedUserId = userId; this.refresh(); } }); this.root.ontransitionend = () => this.callVisibilityChanged(); this.pointerListener = new PointerListener({ element: window, buttonMask: 0b11, callback: this.pointerevent, }); window.addEventListener("keydown", this._onKeyEvent); window.addEventListener("keyup", this._onKeyEvent); /\x2f Use KeyListener to watch for ctrl being held. new KeyListener("Control", this._ctrlWasPressed); /\x2f Work around glitchiness in Chrome's click behavior (if we're in Chrome). new FixChromeClicks(this.root); this.root.addEventListener("mouseover", this.onmouseover, true); this.root.addEventListener("mouseout", this.onmouseout, true); /\x2f If the page is navigated while the popup menu is open, clear the ID the /\x2f user clicked on, so we refresh and show the default. window.addEventListener("pp:popstate", (e) => { if(this._clickedMediaId == null) return; this._setTemporaryIllust(null); }); this._buttonViewManga = this.root.querySelector(".button-view-manga"); this._buttonFullscreen = this.root.querySelector(".button-fullscreen"); this._buttonFullscreen.addEventListener("click", this._clickedFullscreen); this.root.querySelector(".button-zoom").addEventListener("click", this._clickedToggleZoom); this.root.querySelector(".button-browser-back").addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); ppixiv.phistory.back(); }); this.root.addEventListener("click", this._handleLinkClick); this.root.querySelector(".button-parent-folder").addEventListener("click", this.clicked_go_to_parent); for(let button of this.root.querySelectorAll(".button-zoom-level")) button.addEventListener("click", this._clickedZoomLevel); this.avatarWidget = new AvatarWidget({ container: this.root.querySelector(".avatar-widget-container"), mode: "overlay", }); /\x2f Set up the more options dropdown. let moreOptionsButton = this.root.querySelector(".button-more"); this._moreOptionsDropdownOpener = new DropdownBoxOpener({ button: moreOptionsButton, createDropdown: ({...options}) => { let dropdown = new MoreOptionsDropdown({ ...options, parent: this, showExtra: this.altPressed, }); dropdown.root.classList.add("popup-more-options-dropdown"); dropdown.setMediaId(this._effectiveMediaId); dropdown.setUserId(this._effectiveUserId); return dropdown; }, }); moreOptionsButton.addEventListener("click", (e) => { /\x2f Show rarely-used options if alt was pressed. this.altPressed = e.altKey; this._moreOptionsDropdownOpener.visible = !this._moreOptionsDropdownOpener.visible; }); /\x2f Load full media info when the context menu is open, so we can find out if the post /\x2f is in a series. this.getMediaInfo = new GetMediaInfo({ parent: this, neededData: "full", onrefresh: async({mediaInfo}) => { let seriesId = mediaInfo?.seriesNavData?.seriesId; this._buttonViewManga.dataset.popup = mediaInfo == null? "": seriesId != null? "View series":"View manga pages"; let enabled = seriesId != null || mediaInfo?.pageCount > 1; helpers.html.setClass(this._buttonViewManga, "enabled", enabled); this._buttonViewManga.style.pointerEvents = enabled?"":"none"; this._buttonViewManga.querySelector(".manga").hidden = seriesId != null; this._buttonViewManga.querySelector(".series").hidden = seriesId == null; /\x2f Set the manga page or series link. if(enabled) { if(seriesId != null) { let args = new helpers.args("/", ppixiv.plocation); args.path = \`/user/\${mediaInfo.userId}/series/\${seriesId}\`; this._buttonViewManga.href = args.url.toString(); } else { let args = getUrlForMediaId(mediaInfo?.mediaId, { manga: true }); this._buttonViewManga.href = args.url.toString(); } } }, }); this.illustWidgets = [ this.avatarWidget, new LikeButtonWidget({ container: this.root.querySelector(".button-like-container"), template: \`
\` }), new ImageInfoWidget({ container: this.root.querySelector(".context-menu-image-info-container"), }), ]; if(ppixiv.native) { let viewInExplorer = this.root.querySelector(".view-in-explorer"); viewInExplorer.hidden = false; this.illustWidgets.push(new ViewInExplorerWidget({ container: viewInExplorer, })); } /\x2f The bookmark buttons, and clicks in the tag dropdown: this.bookmarkButtons = []; for(let a of this.root.querySelectorAll("[data-bookmark-type]")) { /\x2f The bookmark buttons, and clicks in the tag dropdown: let bookmarkWidget = new BookmarkButtonWidget({ container: a, /\x2f position: relative positions the bookmark count. template: \`
\`, bookmarkType: a.dataset.bookmarkType, }); this.bookmarkButtons.push(bookmarkWidget); this.illustWidgets.push(bookmarkWidget); } /\x2f Set up the bookmark tags dropdown. this.bookmarkTagsDropdownOpener = new BookmarkTagDropdownOpener({ parent: this, bookmarkTagsButton: this.root.querySelector(".button-bookmark-tags"), bookmarkButtons: this.bookmarkButtons, }); this.illustWidgets.push(this.bookmarkTagsDropdownOpener); this.refresh(); } _contextMenuEnabledForElement(element) { let target = element.closest("[data-context-menu-target]"); if(target == null || target.dataset.contextMenuTarget == "off") return false; else return true; } pointerevent = (e) => { if(e.pressed) { if(!this.visible && !this._contextMenuEnabledForElement(e.target)) return; if(!this.visible && e.mouseButton != 1) return; let buttonName = this.buttons[e.mouseButton]; if(buttonName != null) this._buttonsDown[buttonName] = true; if(e.mouseButton != 1) return; /\x2f If invert-popup-hotkey is true, hold shift to open the popup menu. Otherwise, /\x2f hold shift to suppress the popup menu so the browser context menu will open. /\x2f /\x2f Firefox doesn't cancel the context menu if shift is pressed. This seems like a /\x2f well-intentioned but deeply confused attempt to let people override pages that /\x2f block the context menu, making it impossible for us to let you choose context /\x2f menu behavior and probably making it impossible for games to have sane keyboard /\x2f behavior at all. this.shiftWasPressed = e.shiftKey; if(navigator.userAgent.indexOf("Firefox/") == -1 && ppixiv.settings.get("invert-popup-hotkey")) this.shiftWasPressed = !this.shiftWasPressed; if(this.shiftWasPressed) return; e.preventDefault(); e.stopPropagation(); if(this.toggleMode && this.visible) this.hide(); else this.show({x: e.clientX, y: e.clientY, target: e.target}); } else { /\x2f Releasing the left or right mouse button hides the menu if both the left /\x2f and right buttons are released. Pressing right, then left, then releasing /\x2f right won't close the menu until left is also released. This prevents lost /\x2f inputs when quickly right-left clicking. if(!this.visible) return; let buttonName = this.buttons[e.mouseButton]; if(buttonName != null) this._buttonsDown[buttonName] = false; this._hideIfAllButtonsReleased(); } } /\x2f If true, RMB toggles the menu instead of displaying while held, and we'll also hide the /\x2f menu if the mouse moves too far away. get toggleMode() { return ppixiv.settings.get("touchpad-mode", false); } /\x2f The subclass can override this to handle key events. This is called whether the menu /\x2f is open or not. _handleKeyEvent(e) { return false; } _onKeyEvent = (e) => { if(e.repeat) return; /\x2f Don't eat inputs if we're inside an input. if(e.target.closest("input, textarea, [contenteditable]")) return; /\x2f Let the subclass handle events. if(this._handleKeyEvent(e)) { e.preventDefault(); e.stopPropagation(); return; } } _getHoveredElement() { let x = PointerListener.latestMouseClientPosition[0]; let y = PointerListener.latestMouseClientPosition[1]; return document.elementFromPoint(x, y); } _ctrlWasPressed = (down) => { if(!ppixiv.settings.get("ctrl_opens_popup")) return; this._buttonsDown["Control"] = down; if(down) { let x = PointerListener.latestMouseClientPosition[0]; let y = PointerListener.latestMouseClientPosition[1]; let node = this._getHoveredElement(); this.show({x, y, target: node}); } else { this._hideIfAllButtonsReleased(); } } /\x2f This is called on mouseup, and when keyboard shortcuts are released. Hide the menu if all buttons /\x2f that can open the menu have been released. _hideIfAllButtonsReleased() { if(this.toggleMode) return; if(!this._buttonsDown["lmb"] && !this._buttonsDown["rmb"] && !this._buttonsDown["Control"]) this.hide(); } _windowBlur = (e) => { this.hide(); } /\x2f Return the element that should be under the cursor when the menu is opened. get elementToCenter() { return null; } show({x, y, target}) { /\x2f See if the click is inside a ViewerImages. let widget = Widget.fromNode(target, { allowNone: true }); this._currentViewer = null; if(widget) { /\x2f To avoid importing ViewerImages here, just look for a widget in the tree /\x2f with zoomToggle. for(let parent of widget.ancestors({includeSelf: true})) { if(parent.zoomToggle != null) { this._currentViewer = parent; break; } } } /\x2f If RMB is pressed while dragging LMB, stop dragging the window when we /\x2f show the popup. if(this._currentViewer != null) this._currentViewer.stopDragging(); /\x2f See if an element representing a user and/or an illust was under the cursor. if(target != null) { let { mediaId } = ppixiv.app.getMediaIdAtElement(target); this._setTemporaryIllust(mediaId); } if(this.visible) return; this.pointerListener.checkMissedClicks(); this.displayedMenu = this.root; this.visible = true; this.applyVisibility(); OpenWidgets.singleton.set(this, true); /\x2f Disable popup UI while a context menu is open. ClassFlags.get.set("hide-ui", true); window.addEventListener("blur", this._windowBlur); /\x2f Disable all dragging while the context menu is open, since drags cause browsers to /\x2f forget to send mouseup events, which throws things out of whack. We don't use /\x2f drag and drop and there's no real reason to use it while the context menu is open. window.addEventListener("dragstart", this.cancelEvent, true); /\x2f In toggle mode, close the popup if anything outside is clicked. if(this.toggleMode && this.clickOutsideListener == null) { this.clickOutsideListener = new ClickOutsideListener([this.root], () => { this.hide(); }); } let centeredElement = this.elementToCenter; if(centeredElement == null) centeredElement = this.displayedMenu; /\x2f The center of the centered element, relative to the menu. Shift the center /\x2f down a bit in the button. let pos = helpers.html.getRelativePosition(centeredElement, this.displayedMenu); pos[0] += centeredElement.offsetWidth / 2; pos[1] += centeredElement.offsetHeight * 3 / 4; x -= pos[0]; y -= pos[1]; this.popupPosition = { x, y }; this.setCurrentPosition(); /\x2f Start listening for the window moving. this.addWindowMovementListeners(); /\x2f Adjust the fade-in so it's centered around the centered element. this.displayedMenu.style.transformOrigin = (pos[0]) + "px " + (pos[1]) + "px"; HideMouseCursorOnIdle.disableAll("contextMenu"); /\x2f Make sure we're up to date if we deferred an update while hidden. this.refresh(); } setCurrentPosition() { let { x, y } = this.popupPosition; if(this._currentViewer == null) { /\x2f If we can't zoom, adjust the popup position so it doesn't go over the right and /\x2f bottom of the screen, with a bit of padding so we're not flush with the edge and /\x2f so the popup text is visible. /\x2f /\x2f If zooming is enabled (we're viewing an image), always align to the same place, /\x2f so the cursor is always over the zoom toggle button. let windowWidth = window.innerWidth - 4; let windowHeight = window.innerHeight - 20; x = helpers.math.clamp(x, 0, windowWidth - this.displayedMenu.offsetWidth); y = helpers.math.clamp(y, 0, windowHeight - this.displayedMenu.offsetHeight); } this.displayedMenu.style.left = \`\${x}px\`; this.displayedMenu.style.top = \`\${y}px\`; } /\x2f Try to keep the context menu in the same place on screen when we toggle fullscreen. /\x2f /\x2f To do this, we need to know when the position of the client area on the screen changes. /\x2f There are no APIs to query this directly (window.screenX/screenY don't work, those are /\x2f the position of the window rather than the client area). Figure it out by watching /\x2f mouse events, and comparing the client and screen position of the cursor. If it's 100x50, the /\x2f client area is at 100x50 on the screen. /\x2f /\x2f It's not perfect, but it helps keep the context menu from being way off in another part /\x2f of the screen after toggling fullscreen. addWindowMovementListeners() { /\x2f Firefox doesn't send any mouse events at all when the window moves (not even focus /\x2f changes), which makes this look weird since it doesn't update until the mouse moves. /\x2f Just disable it on Firefox. if(navigator.userAgent.indexOf("Firefox/") != -1) return; if(this.removeWindowMovementListeners != null) return; this.lastOffset = null; let controller = new AbortController(); let signal = controller.signal; signal.addEventListener("abort", () => { this.removeWindowMovementListeners = null; }); /\x2f Call this.removeWindowMovementListeners() to turn this back off. this.removeWindowMovementListeners = controller.abort.bind(controller); /\x2f Listen for hover events too. We don't get mousemouve events if the window changes /\x2f but the mouse doesn't move, but the hover usually does change. for(let event of ["mouseenter", "mouseleave", "mousemove", "mouseover", "mouseout"]) { window.addEventListener(event, this._onMousePositionChanged, { capture: true, signal }); } } _onMousePositionChanged = (e) => { if(!this.visible) throw new Error("Expected to be visible"); /\x2f The position of the client area onscreen. If we have client scaling, this is /\x2f in client units. let windowX = e.screenX/window.devicePixelRatio - e.clientX; let windowY = e.screenY/window.devicePixelRatio - e.clientY; /\x2f Stop if it hasn't changed. screenX/devicePixelRatio can be fractional and not match up /\x2f with clientX exactly, so ignore small changes. if(this.lastOffset != null && Math.abs(windowX - this.lastOffset.x) <= 1 && Math.abs(windowY - this.lastOffset.y) <= 1) return; let previous = this.lastOffset; this.lastOffset = { x: windowX, y: windowY }; if(previous == null) return; /\x2f If the window has moved by 20x10, move the context menu by -20x-10. let windowDeltaX = windowX - previous.x; let windowDeltaY = windowY - previous.y; this.popupPosition.x -= windowDeltaX; this.popupPosition.y -= windowDeltaY; this.setCurrentPosition(); }; /\x2f If element is within a button that has a tooltip set, show it. _showTooltipForElement(element) { if(element != null) element = element.closest("[data-popup]"); if(this._tooltipElement == element) return; this._tooltipElement = element; this._refreshTooltip(); if(this._tooltipObserver) { this._tooltipObserver.disconnect(); this._tooltipObserver = null; } if(this._tooltipElement == null) return; /\x2f Refresh the tooltip if the popup attribute changes while it's visible. this._tooltipObserver = new MutationObserver((mutations) => { for(let mutation of mutations) { if(mutation.type == "attributes") { if(mutation.attributeName == "data-popup") this._refreshTooltip(); } } }); this._tooltipObserver.observe(this._tooltipElement, { attributes: true }); } _refreshTooltip() { let element = this._tooltipElement; if(element != null) element = element.closest("[data-popup]"); this.root.querySelector(".tooltip-display").hidden = element == null; if(element != null) this.root.querySelector(".tooltip-display-text").dataset.popup = element.dataset.popup; } onmouseover = (e) => { this._showTooltipForElement(e.target); } onmouseout = (e) => { this._showTooltipForElement(e.relatedTarget); } get hideTemporarily() { return this._hiddenTemporarily; } set hideTemporarily(value) { this._hiddenTemporarily = value; this.callVisibilityChanged(); } /\x2f True if the widget is active (eg. RMB is pressed) and we're not hidden /\x2f by a zoom. get actuallyVisible() { if(this.visible) return true; /\x2f We're still visible if we're becoming hidden but we still have animations running. if(this.root.getAnimations().length > 0) return true; return false; } visibilityChanged() { super.visibilityChanged(); OpenWidgets.singleton.set(this, this.visible); } applyVisibility() { let visible = this.visible && !this._hiddenTemporarily; helpers.html.setClass(this.root, "hidden-widget", !visible); helpers.html.setClass(this.root, "visible", visible); } hide() { /\x2f For debugging, this can be set to temporarily force the context menu to stay open. if(window.keepContextMenuOpen) return; if(!this.visible) return; this.visible = false; this._hiddenTemporarily = false; this.applyVisibility(); OpenWidgets.singleton.set(this, false); this.displayedMenu = null; HideMouseCursorOnIdle.enableAll("contextMenu"); this._buttonsDown = {}; ClassFlags.get.set("hide-ui", false); window.removeEventListener("blur", this._windowBlur); window.removeEventListener("dragstart", this.cancelEvent, true); if(this.clickOutsideListener) { this.clickOutsideListener.shutdown(); this.clickOutsideListener = null; } if(this.removeWindowMovementListeners) this.removeWindowMovementListeners(); } cancelEvent = (e) => { e.preventDefault(); e.stopPropagation(); } /\x2f Override ctrl-clicks inside the context menu. /\x2f /\x2f This is a bit annoying. Ctrl-clicking a link opens it in a tab, but we allow opening the /\x2f context menu by holding ctrl, which means all clicks are ctrl-clicks if you use the popup /\x2f that way. We work around this by preventing ctrl-click from opening links in a tab and just /\x2f navigate normally. This is annoying since some people might like opening tabs that way, but /\x2f there's no other obvious solution other than changing the popup menu hotkey. That's not a /\x2f great solution since it needs to be on Ctrl or Alt, and Alt causes other problems, like showing /\x2f the popup menu every time you press alt-left. /\x2f /\x2f This only affects links inside the context menu, which is currently only the author link, and /\x2f most people probably use middle-click anyway, so this will have to do. _handleLinkClick = (e) => { /\x2f Do nothing if opening the popup while holding ctrl is disabled. if(!ppixiv.settings.get("ctrl_opens_popup")) return; let a = e.target.closest("A"); if(a == null) return; /\x2f If a previous event handler called preventDefault on this click, ignore it. if(e.defaultPrevented) return; /\x2f Only change ctrl-clicks. if(e.altKey || e.shiftKey || !e.ctrlKey) return; e.preventDefault(); e.stopPropagation(); let url = new URL(a.href, ppixiv.plocation); helpers.navigate(url); } visibilityChanged(value) { super.visibilityChanged(value); if(this.visible) window.addEventListener("wheel", this.onwheel, { capture: true, /\x2f Work around Chrome intentionally breaking event listeners. Remember when browsers /\x2f actually made an effort to not break things? passive: false, }); else window.removeEventListener("wheel", this.onwheel, true); } /\x2f Return the media ID active in the context menu, or null if none. /\x2f /\x2f If we're opened by right clicking on an illust, we'll show that image's /\x2f info. Otherwise, we'll show the info for the illust we're on, if any. get _effectiveMediaId() { let mediaId = this._clickedMediaId ?? this._mediaId; if(mediaId == null) return null; /\x2f Don't return users this way. They'll be returned by _effectiveUserId. let { type } = helpers.mediaId.parse(mediaId); if(type == "user") return null; return mediaId; } get _effectiveUserId() { /\x2f See if getUserIdFromMediaId has loaded the user ID. return this.getUserIdFromMediaId.info.userId; } setMediaId(mediaId) { if(this._mediaId == mediaId) return; this._mediaId = mediaId; this.getUserIdFromMediaId.id = this._clickedMediaId ?? this._mediaId; this.refresh(); } /\x2f Put the zoom toggle button under the cursor, so right-left click is a quick way /\x2f to toggle zoom lock. get elementToCenter() { return this.displayedMenu.querySelector(".button-zoom"); } get _isZoomUiEnabled() { return this._currentViewer != null && this._currentViewer.slideshowMode == null; } setDataSource(dataSource) { if(this.dataSource == dataSource) return; this.dataSource = dataSource; for(let widget of this.illustWidgets) { if(widget.setDataSource) widget.setDataSource(dataSource); } this.refresh(); } /\x2f Handle key events. This is called whether the context menu is open or closed, and handles /\x2f global hotkeys. This is handled here because it has a lot of overlapping functionality with /\x2f the context menu. /\x2f /\x2f The actual actions may happen async, but this always returns synchronously since the keydown/keyup /\x2f event needs to be defaultPrevented synchronously. /\x2f /\x2f We always return true for handled hotkeys even if we aren't able to perform them currently, so /\x2f keys don't randomly revert to default actions. _handleKeyEventForImage(e) { /\x2f These hotkeys require an image, which we have if we're viewing an image or if the user /\x2f was hovering over an image in search results. We might not have the illust info yet, /\x2f but we at least need an illust ID. let mediaId = this._effectiveMediaId; /\x2f If there's no effective media ID, the user is pressing a key while the context menu isn't /\x2f open. If the cursor is over a search thumbnail, use its media ID if any, to allow hovering /\x2f over a thumbnail and using bookmark, etc. hotkeys. This isn't needed when ctrl_opens_popup /\x2f is open since we'll already have _effectiveMediaId. if(mediaId == null) { let node = this._getHoveredElement(); mediaId = ppixiv.app.getMediaIdAtElement(node).mediaId; } /\x2f Handle VVbrowser-specific hotkeys. if(LocalAPI.isVVbrowser()) { /\x2f Handle alt-left and alt-right for navigation. This isn't done by VVBrowser itself. /\x2f Don't use phistory here. It doesn't handle forwards navigation, and we know /\x2f we're not in phistory permanent mode since VVbrowser isn't used on mobile. if(e.altKey && e.key == "ArrowLeft") { navigation.back(); e.preventDefault(); } else if(e.altKey && e.key == "ArrowRight") { navigation.forward(); e.preventDefault(); } } /\x2f All of these hotkeys require Ctrl. if(!e.ctrlKey) return; if(e.key.toUpperCase() == "V") { if(mediaId == null) return; Actions.likeImage(mediaId); return true; } if(e.key.toUpperCase() == "B") { (async() => { if(mediaId == null) return; let mediaInfo = ppixiv.mediaCache.getMediaInfo(mediaId, { full: false }); /\x2f Ctrl-Shift-Alt-B: add a bookmark tag if(e.altKey && e.shiftKey) { Actions.addNewBookmarkTag(mediaId); return; } /\x2f Ctrl-Shift-B: unbookmark if(e.shiftKey) { if(mediaInfo.bookmarkData == null) { ppixiv.message.show("Image isn't bookmarked"); return; } Actions.bookmarkRemove(mediaId); return; } /\x2f Ctrl-B: bookmark with default privacy /\x2f Ctrl-Alt-B: bookmark privately let bookmarkPrivately = null; if(e.altKey) bookmarkPrivately = true; if(mediaInfo.bookmarkData != null) { ppixiv.message.show("Already bookmarked (^B to remove bookmark)"); return; } Actions.bookmarkAdd(mediaId, { private: bookmarkPrivately }); })(); return true; } if(e.key.toUpperCase() == "P") { let enable = !ppixiv.settings.get("auto_pan", false); ppixiv.settings.set("auto_pan", enable); ppixiv.message.show(\`Image panning \${enable? "enabled":"disabled"}\`); return true; } if(e.key.toUpperCase() == "S") { /\x2f Go async to get media info if it's not already available. (async() => { if(mediaId == null) return; /\x2f Download the image or video by default. If alt is pressed and the image has /\x2f multiple pages, download a ZIP instead. let mediaInfo = await ppixiv.mediaCache.getMediaInfo(mediaId, { full: false }); let downloadType = "image"; if(Actions.isDownloadTypeAvailable("image", mediaInfo)) downloadType = "image"; else if(Actions.isDownloadTypeAvailable("MKV", mediaInfo)) downloadType = "MKV"; if(e.altKey && Actions.isDownloadTypeAvailable("ZIP", mediaInfo)) downloadType = "ZIP"; Actions.downloadIllust(mediaId, downloadType); })(); return true; } return false; } _handleKeyEventForUser(e) { /\x2f These hotkeys require a user, which we have if we're viewing an image, if the user /\x2f was hovering over an image in search results, or if we're viewing a user's posts. /\x2f We might not have the user info yet, but we at least need a user ID. let userId = this._effectiveUserId; /\x2f All of these hotkeys require Ctrl. if(!e.ctrlKey) return; if(e.key.toUpperCase() == "F") { (async() => { if(userId == null) return; let userInfo = await ppixiv.userCache.getUserInfo(userId, { full: true }); if(userInfo == null) return; /\x2f Ctrl-Shift-F: unfollow if(e.shiftKey) { if(!userInfo.isFollowed) { ppixiv.message.show("Not following this user"); return; } await Actions.unfollow(userId); return; } /\x2f Ctrl-F: follow with default privacy /\x2f Ctrl-Alt-F: follow privately /\x2f /\x2f It would be better to check if we're following publically or privately to match the hotkey, but /\x2f Pixiv doesn't include that information. let followPrivately = null; if(e.altKey) followPrivately = true; if(userInfo.isFollowed) { ppixiv.message.show("Already following this user"); return; } await Actions.follow(userId, followPrivately); })(); return true; } return false; } _handleKeyEvent(e) { if(e.type != "keydown") return false; if(e.altKey && e.key == "Enter") { helpers.toggleFullscreen(); return true; } if(this._isZoomUiEnabled) { /\x2f Ctrl-0 toggles zoom, similar to the browser Ctrl-0 reset zoom hotkey. if(e.code == "Digit0" && e.ctrlKey) { e.preventDefault(); e.stopImmediatePropagation(); this._currentViewer.zoomToggle(); return; } let zoom = helpers.isZoomHotkey(e); if(zoom != null) { e.preventDefault(); e.stopImmediatePropagation(); this._handleZoomEvent(e, zoom < 0); return true; } } /\x2f Check image and user hotkeys. if(this._handleKeyEventForImage(e)) return true; if(this._handleKeyEventForUser(e)) return true; return false; } onwheel = (e) => { /\x2f RMB-wheel zooming is confusing in toggle mode. if(this.toggleMode) return; /\x2f Stop if zooming isn't enabled. if(!this._isZoomUiEnabled) return; /\x2f Stop if the user dropdown is open. let userDropdown = this.avatarWidget.userDropdownWidget; if(userDropdown) { /\x2f If the input isn't inside the dropdown, prevent the input so we don't navigate /\x2f while the dropdown is open. Otherwise, leave it alone to allow scrolling the /\x2f dropdown. This includes submenus (the bookmark tag dropdown). let targetWidget = Widget.fromNode(e.target); if(targetWidget) { if(!userDropdown.isAncestorOf(targetWidget)) { e.preventDefault(); e.stopImmediatePropagation(); } } return; } /\x2f Only mousewheel zoom if the popup menu is visible. if(!this.visible) return; /\x2f We want to override almost all mousewheel events while the popup menu is open, but /\x2f don't override scrolling the popup menu's tag list. if(e.target.closest(".popup-bookmark-tag-dropdown")) return; e.preventDefault(); e.stopImmediatePropagation(); let down = e.deltaY > 0; this._handleZoomEvent(e, down); } /\x2f Handle both mousewheel and control-+/- zooming. _handleZoomEvent(e, down) { e.preventDefault(); e.stopImmediatePropagation(); if(!this.hideTemporarily) { /\x2f Hide the popup menu. It remains open, so hide() will still be called when /\x2f the right mouse button is released and the overall flow remains unchanged, but /\x2f the popup itself will be hidden. this.hideTemporarily = true; } /\x2f If e is a keyboard event, use null to use the center of the screen. let keyboard = e instanceof KeyboardEvent; let x = keyboard? null:e.clientX; let y = keyboard? null:e.clientY; this._currentViewer.zoomAdjust(down, {x, y}); this.refresh(); } /\x2f Set an alternative illust ID to show. This is effective until the context menu is hidden. /\x2f This is used to remember what the cursor was over when the context menu was opened when in /\x2f the search view. _setTemporaryIllust(mediaId) { if(this._clickedMediaId == mediaId) return; this._clickedMediaId = mediaId; this.getUserIdFromMediaId.id = this._clickedMediaId ?? this._mediaId; this.refresh(); } /\x2f Update selection highlight for the context menu. refresh() { let mediaId = this._effectiveMediaId; if(this.visible) this.getMediaInfo.id = mediaId; /\x2f If we're not visible, don't refresh an illust until we are, so we don't trigger /\x2f data loads. Do refresh even if we're hidden if we have no illust to clear /\x2f the previous illust's display even if we're not visible, so it's not visible the /\x2f next time we're displayed. if(!this.visible && mediaId != null) return; let userId = this._effectiveUserId; helpers.html.setClass(this._buttonFullscreen, "selected", helpers.isFullscreen()); this._refreshTooltip(); /\x2f Enable the zoom buttons if we're in the image view and we have an ViewerImages. for(let element of this.root.querySelectorAll(".button.requires-zoom")) helpers.html.setClass(element, "enabled", this._isZoomUiEnabled); /\x2f If we're visible, tell widgets what we're viewing. Don't do this if we're not visible, so /\x2f they don't load data unnecessarily. Don't set these back to null if we're hidden, so they /\x2f don't blank themselves while we're still fading out. if(this.visible) { for(let widget of this.illustWidgets) { if(widget.setMediaId) widget.setMediaId(mediaId); if(widget.setUserId) widget.setUserId(userId); /\x2f If _clickedMediaId is set, we're open for a search result image the user right-clicked /\x2f on. Otherwise, we're open for the image actually being viewed. Tell ImageInfoWidget /\x2f to show the current manga page if we're on a viewed image, but not if we're on a search /\x2f result. let showingViewedImage = (this._clickedMediaId == null); widget.showPageNumber = showingViewedImage; } /\x2f If we're on a local ID, show the parent folder button. Otherwise, show the /\x2f author button. We only show one or the other of these. /\x2f /\x2f If we don't have an illust ID, see if the data source has a folder ID, so this /\x2f works when right-clicking outside thumbs on search pages. let folderButton = this.root.querySelector(".button-parent-folder"); let authorButton = this.root.querySelector(".avatar-widget-container"); let isLocal = helpers.mediaId.isLocal(this._folderIdForParent); folderButton.hidden = !isLocal; authorButton.hidden = isLocal; helpers.html.setClass(folderButton, "enabled", this._parentFolderId != null); this.querySelector(".private-bookmark-button").hidden = isLocal; } if(this._isZoomUiEnabled) { helpers.html.setClass(this.root.querySelector(".button-zoom"), "selected", this._currentViewer.getLockedZoom()); let zoomLevel = this._currentViewer.getZoomLevel(); for(let button of this.root.querySelectorAll(".button-zoom-level")) helpers.html.setClass(button, "selected", this._currentViewer.getLockedZoom() && button.dataset.level == zoomLevel); } } _clickedFullscreen = async (e) => { e.preventDefault(); e.stopPropagation(); await helpers.toggleFullscreen(); this.refresh(); } /\x2f "Zoom lock", zoom as if we're holding the button constantly _clickedToggleZoom = (e) => { e.preventDefault(); e.stopPropagation(); if(!this._isZoomUiEnabled) return; this._currentViewer.zoomToggle({x: e.clientX, y: e.clientY}) this.refresh(); } _clickedZoomLevel = (e) => { e.preventDefault(); e.stopPropagation(); if(!this._isZoomUiEnabled) return; this._currentViewer.zoomSetLevel(e.currentTarget.dataset.level, {x: e.clientX, y: e.clientY}); this.refresh(); } /\x2f Return the illust ID whose parent the parent button will go to. get _folderIdForParent() { if(this._effectiveMediaId != null) return this._effectiveMediaId; let dataSourceMediaId = this.dataSource?.uiInfo.mediaId; if(helpers.mediaId.isLocal(dataSourceMediaId)) return dataSourceMediaId; return null; } /\x2f Return the folder ID that the parent button goes to. get _parentFolderId() { let folderId = this._folderIdForParent; let isLocal = helpers.mediaId.isLocal(folderId); if(!isLocal) return null; /\x2f Go to the parent of the item that was clicked on. let parentFolderId = LocalAPI.getParentFolder(folderId); /\x2f If the user right-clicked a thumbnail and its parent is the folder we're /\x2f already displaying, go to the parent of the folder instead (otherwise we're /\x2f linking to the page we're already on). This makes the parent button make /\x2f sense whether you're clicking on an image in a search result (go to the /\x2f location of the image), while viewing an image (also go to the location of /\x2f the image), or in a folder view (go to the folder's parent). let currentlyDisplayingId = LocalAPI.getLocalIdFromArgs(helpers.args.location); if(parentFolderId == currentlyDisplayingId) parentFolderId = LocalAPI.getParentFolder(parentFolderId); return parentFolderId; } clicked_go_to_parent = (e) => { e.preventDefault(); let parentFolderId = this._parentFolderId; if(parentFolderId == null) return; let args = new helpers.args("/", ppixiv.plocation); LocalAPI.getArgsForId(parentFolderId, args); helpers.navigate(args.url); } } class ImageInfoWidget extends IllustWidget { constructor({ showTitle=false, ...options}) { super({ ...options, template: \`
\`}); this.showTitle = showTitle; } get neededData() { /\x2f We need illust info if we're viewing a manga page beyond page 1, since /\x2f early info doesn't have that. Most of the time, we only need early info. let mangaPage = this.mangaPage; if(mangaPage == null || mangaPage == 0) return "partial"; else return "full"; } set showPageNumber(value) { this._showPageNumber = value; this.refresh(); } refreshInternal({ mediaId, mediaInfo }) { this.root.hidden = mediaInfo == null; if(this.root.hidden) return; let setInfo = (query, text) => { let node = this.root.querySelector(query); node.innerText = text; node.hidden = text == ""; }; /\x2f Add the page count for manga. If the data source is dataSource.vview, show /\x2f the index of the current file if it's loaded all results. let pageCount = mediaInfo.pageCount; let pageText = this.dataSource.getPageTextForMediaId(mediaId); if(pageText == null && pageCount > 1) { let currentPage = this.mangaPage; if(this._showPageNumber || currentPage > 0) pageText = \`Page \${currentPage+1}/\${pageCount}\`; else pageText = \`\${pageCount} pages\`; } setInfo(".page-count", pageText ?? ""); if(this.showTitle) { setInfo(".title", mediaInfo.illustTitle); let showFolder = helpers.mediaId.isLocal(this._mediaId); this.root.querySelector(".folder-block").hidden = !showFolder; if(showFolder) { let {id} = helpers.mediaId.parse(this._mediaId); this.root.querySelector(".folder-text").innerText = helpers.strings.getPathSuffix(id, 1, 1); /\x2f parent directory } } /\x2f If we're on the first page then we only requested early info, and we can use the dimensions /\x2f on it. Otherwise, get dimensions from mangaPages from illust data. If we're displaying a /\x2f manga post and we don't have illust data yet, we don't have dimensions, so hide it until /\x2f it's loaded. let info = ""; let { width, height } = ppixiv.mediaCache.getImageDimensions(mediaInfo, this._mediaId); if(width != null && height != null) info += width + "x" + height; setInfo(".image-info", info); let secondsOld = (new Date() - new Date(mediaInfo.createDate)) / 1000; let age = helpers.strings.ageToString(secondsOld); this.root.querySelector(".post-age").dataset.popup = helpers.strings.dateToString(mediaInfo.createDate); setInfo(".post-age", age); } setDataSource(dataSource) { if(this.dataSource == dataSource) return; this.dataSource = dataSource; this.refresh(); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/context-menu.js `), "/vview/screen.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; /\x2f The base class for our main screens. export default class Screen extends Widget { /\x2f Return "screen" or "illust". get screenType() { return null; } /\x2f Handle a key input. This is only called while the screen is active. handleKeydown(e) { } /\x2f Return the media ID being displayed, or null if none. get displayedMediaId() { return null; } /\x2f Screens don't hide themselves when visible is false, but we still set visibility so /\x2f visibleRecursively works. applyVisibility() { } get active() { return !this.root.inert; } /\x2f The screen is becoming active. This is async, since it may load data. async activate() { this.root.inert = false; } /\x2f The screen is becoming inactive. This is sync, since we never need to stop to /\x2f load data in order to deactivate. deactivate() { this.root.inert = true; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/screen.js `), "/vview/actors/actor.js": loadBlob("application/javascript", `/\x2f Actor is the base class for the actor tree. Actors can have parent and child actors. /\x2f Shutting down an actor will shut down its children. Each actor has an AbortSignal /\x2f which is aborted when the actor shuts down, so event listeners, fetches, etc. can be /\x2f shut down with the actor. /\x2f /\x2f Most actors are widgets and should derive from ppixiv.widget. The base actor class /\x2f doesn't have HTML content or add itself to the DOM tree. Non-widget actors are used /\x2f for helpers that want to live in the actor tree, but don't have content of their own. import { helpers } from '/vview/misc/helpers.js'; let templatesCache = new Map(); export default class Actor extends EventTarget { /\x2f If true, stack traces will be logged if shutdown() is called more than once. This takes /\x2f a stack trace on each shutdown, so it's only enabled when needed. static _debugShutdown = false; /\x2f A list of top-level actors (actors with no parent). This is just for debugging. static _topActors = []; /\x2f Dump the actor tree to the console. static dumpActors({parent=null}={}) { let actors = parent? parent.children:Actor._topActors; let grouped = false; if(parent) { /\x2f If this parent has any children, create a logging group. Otherwise, just log it normally. if(actors.length == 0) console.log(parent); else { console.group(parent); grouped = true; } } try { for(let actor of actors) Actor.dumpActors({parent: actor}); } finally { /\x2f Only remove the logging group if we created one. if(grouped) console.groupEnd(); } } constructor({ /\x2f The parent actor, if any. parent=null, /\x2f The actor will be shut down if this is aborted. signal=null, ...options }={}) { super(); this.options = options; this.parent = parent; this.children = []; /\x2f Create our shutdownSignal. We'll abort this if we're shut down to shut down our children. /\x2f This is always shut down by us when shutdown() is called (it isn't used to shut us down). this._shutdownSignalController = new AbortController(); this.shutdownSignal = this._shutdownSignalController.signal; /\x2f If we weren't given a shutdown signal explicitly and we have a parent actor, inherit /\x2f its signal, so we'll shut down when the parent does. if(signal == null && this.parent != null) signal = this.parent.shutdownSignal; /\x2f If we were given a parent shutdown signal, shut down if it aborts. if(signal) signal.addEventListener("abort", () => this.shutdown(), { once: true, ...this._signal }); /\x2f Register ourself in our parent's child list. if(this.parent) this.parent._childAdded(this); else Actor._topActors.push(this); } get className() { return this.__proto__.constructor.name; } get hasShutdown() { return this.shutdownSignal.aborted; } shutdown() { if(Actor._debugShutdown && !this._previousShutdownStack) { try { throw new Error(); } catch(e) { this._previousShutdownStack = e.stack; } } /\x2f We should only be shut down once, so shutdownSignal shouldn't already be signalled. if(this.hasShutdown) { console.error("Actor has already shut down:", this); if(this._previousShutdownStack) console.log("Previous shutdown stack:", this._previousShutdownStack); return; } /\x2f This will shut down everything associated with this actor, as well as any child actors. this._shutdownSignalController.abort(); /\x2f All of our children should have shut down and removed themselves from our child list. if(this.children.length != 0) { for(let child of this.children) console.warn("Child of", this, "didn't shut down:", child); } /\x2f If we have a parent, remove ourself from it. Otherwise, remove ourself from /\x2f _topActors. if(this.parent) this.parent._childRemoved(this); else { let idx = Actor._topActors.indexOf(this); console.assert(idx != -1); Actor._topActors.splice(idx, 1); } } /\x2f Create an element from template HTML. If name isn't null, the HTML will be cached /\x2f using name as a key. createTemplate({name=null, html, makeSVGUnique=true}) { let template = name? this._templatesCache[name]:null; if(!template) { template = document.createElement("template"); template.innerHTML = html; helpers.replaceInlines(template.content); if(name) this._templatesCache[name] = template; } return helpers.html.createFromTemplate(template, { makeSVGUnique }); } /\x2f Cache templates separately for each class. This doesn't share cache between subclasses, /\x2f but it lets us reuse templates between instances. get _templatesCache() { let cache = templatesCache.get(this.constructor) if(cache != null) return cache; cache = {}; templatesCache.set(this.constructor, cache); return cache; } /\x2f For convenience, return options to add to an event listener and other objects that /\x2f take an AbortSignal to shut down when the rest of the actor does. /\x2f /\x2f node.addEventListener("event", func, this._signal); /\x2f node.addEventListener("event", func, { capture: true, ...this._signal }); get _signal() { return { signal: this.shutdownSignal }; } _childAdded(child) { this.children.push(child); } _childRemoved(child) { let idx = this.children.indexOf(child); if(idx == -1) { console.warn("Actor wasn't in the child list:", child); return; } this.children.splice(idx, 1); } /\x2f Yield all parents of this node. If includeSelf is true, yield ourself too. *ancestors({includeSelf=false}={}) { if(includeSelf) yield this; let count = 0; let parent = this.parent; while(parent != null) { yield parent; parent = parent.parent; count++; if(count > 10000) throw new Error("Recursion detected"); } } /\x2f Yield all descendants of this node, depth-first. If includeSelf is true, yield ourself too. *descendents({includeSelf=false}={}) { if(includeSelf) yield this; for(let child of this.children) { yield child; for(let childDescendants of child.descendents()) yield childDescendants; } } /\x2f Return true if widget is a descendant of this node. isAncestorOf(widget) { for(let ancestor of widget.ancestors({includeSelf: true})) if(ancestor == this) return true; return false; } /\x2f Return all DOM roots within this actor. See Widget.getRoots(). getRoots() { /\x2f We're not an actor, so all of our children's roots are our roots. let result = []; for(let child of this.children) result = [...result, ...child.getRoots()]; return result; } /\x2f See Widget for information about visibility. Non-widget actors are always visible. get visible() { return true; } get actuallyVisible() { return true; } /\x2f Return true if we and all of our ancestors are visible. /\x2f /\x2f This is based on this.visible. For widgets that animate on and off, this becomes false /\x2f as soon as the widget begins hiding (this.visible becomes false), without waiting for the /\x2f animation to finish (this.actuallyVisible). This allows child widgets to animate away /\x2f along with the parent. get visibleRecursively() { if(!this.visible) return false; if(this.parent == null) return true; return this.parent.visibleRecursively; } get actuallyVisibleRecursively() { if(!this.actuallyVisible) return false; if(this.parent == null) return true; return this.parent.actuallyVisibleRecursively; } /\x2f Call this when this.visible or this.actuallyVisible may have changed. callVisibilityChanged() { for(let actor of this.descendents({includeSelf: true})) { actor.visibilityChanged(); } } /\x2f This is called when visibleRecursively or actuallyVisibleRecursively may have changed. visibilityChanged() { } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/actors/actor.js `), "/vview/actors/async-lookup.js": loadBlob("application/javascript", `/\x2f AsyncLookups handle a common pattern for looking up data for an ID, usually for display. /\x2f onrefresh will be called with the result once it becomes available. If the data isn't /\x2f available immediately, it will be called with an empty result until data becomes available, /\x2f so the UI can be cleared. import Actor from '/vview/actors/actor.js'; import { helpers } from '/vview/misc/helpers.js'; export default class AsyncLookup extends Actor { constructor({ /\x2f The initial ID to look up. id=null, /\x2f This is called when the results change. onrefresh=async ({}) => { }, /\x2f If false, we won't make API requests to load data if we're not active. If we already /\x2f have data it'll still be provided. loadWhileNotVisible=false, ...options }) { super({...options}); this._onrefresh = onrefresh; this._loadWhileNotVisible = loadWhileNotVisible; this._id = id; this._info = { }; /\x2f Defer the initial refresh so we don't call onrefresh before the constructor returns. helpers.other.defer(() => this.refresh()); } /\x2f Set the ID we're looking up. get id() { return this._id; } set id(value) { if(this._id == value) return; this._id = value; this.refresh(); } /\x2f Return the most recent info given to onrefresh. get info() { return this._info ?? { }; } visibilityChanged() { super.visibilityChanged(); /\x2f If we might have skipped loading while not visible, refresh now. Use visibleRecursively /\x2f for this and not actuallyVisibleRecursively so we don't refresh while we're transitioning /\x2f away. if(!this._loadWhileNotVisible && this.visibleRecursively) this.refresh(); } async refresh() { if(this.hasShutdown) return; this._refreshInner(); } /\x2f The subclass should implement this. async _refreshInner() { } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/actors/async-lookup.js `), "/vview/actors/direct-animation.js": loadBlob("application/javascript", `/\x2f DirectAnimation is an Animation where we manually run its clock instead of letting it /\x2f happen async. /\x2f /\x2f This works around some problems with Chrome's implementation: /\x2f /\x2f - It always runs at the maximum possible refresh rate. My main display is 280Hz freesync, /\x2f which is nice for scrolling and mouse cursors and games, but it's a waste of resources to /\x2f pan an image around at that speed. Chrome doesn't give any way to control this. /\x2f - It runs all windows at the maximum refresh rate of any attached monitor. My secondary /\x2f monitors are regular 60Hz, but Chrome runs animations on them at 280Hz too. (This is a /\x2f strange bug: the entire point of requestAnimationFrame is to sync to vsync, not to just /\x2f wait for however long the browser thinks a frame is.) /\x2f - Running animations at this framerate causes other problems, like hitches in thumbnail /\x2f animations and videos in unrelated windows freezing. (Is Chrome still only tested with /\x2f 60Hz monitors?) /\x2f /\x2f Running the animation directly lets us control the framerate we actually update at. /\x2f /\x2f It also works around problems with iOS's implementation: pausing animations causes the /\x2f playback time to jump backwards, instead of synchronizing with the async timer. This /\x2f causes DragImageChanger to jump around when drags are interrupted. /\x2f /\x2f Running the animation directly is OK for us since the animation is usually the only thing /\x2f going on, and we're not trying to use this to drive a bunch of random animations. /\x2f /\x2f This only implements what we need to run slideshow animations and doesn't attempt to be a /\x2f general drop-in replacement for Animation. It'll cause JS to be run periodically instead of /\x2f letting everything happen in the compositor, but that's much better than updating multiple /\x2f windows at several times their actual framerate. import { helpers } from '/vview/misc/helpers.js'; export default class DirectAnimation { constructor(effect, { /\x2f If false, framerate limiting is disabled. limitFramerate=true, }={}) { this._limitFramerate = limitFramerate; /\x2f We should be able to just subclass Animation, and this works in Chrome, but iOS Safari /\x2f is broken and doesn't call overridden functions. this.animation = new Animation(effect); this._updatePlayState("idle"); } get effect() { return this.animation.effect; } _updatePlayState(state) { if(state == this._playState) return; /\x2f If we're exiting finished, create a new finished promise. if(this.finished == null || this._playState == "finished") { this.finished = helpers.other.makePromise(); /\x2f Catch this promise by default, so errors aren't logged to the console every time /\x2f an animation is cancelled. this.finished.catch((f) => true); } this._playState = state; } play() { if(this._playState == "running") return; this._updatePlayState("running"); this._playToken = new Object(); this._runner = this._runAnimation(); } pause() { if(this._playState == "paused") return; this._updatePlayState("paused"); this._playToken = null; this._runner = null; } cancel() { this.pause(); this.animation.cancel(); } updatePlaybackRate(rate) { return this.animation.updatePlaybackRate(rate); } commitStyles() { this.animation.commitStyles(); } commitStylesIfPossible() { try { this.commitStyles(); return true; } catch(e) { console.error(e); return false; } } get playState() { return this._playState; } get currentTime() { return this.animation.currentTime; } async _runAnimation() { this.animation.currentTime = this.animation.currentTime; let token = this._playToken; let lastUpdate = Date.now(); /\x2f If no time has been set yet, the animation hasn't applied any styles. Set the default /\x2f start time before going async, so we don't flash whatever the previous style was for a /\x2f frame before updating. if(this.animation.currentTime == null) this.animation.currentTime = 0; while(1) { let delta; while(1) { await helpers.other.vsync(); /\x2f Stop if the animation state changed while we were async. if(token !== this._playToken) { this.finished.reject(new DOMException("The animation was aborted", "AbortError")); return; } let now = Date.now(); delta = now - lastUpdate; /\x2f If we're running faster than we want, wait another frame, giving a small error margin. /\x2f If targetFramerate is null, just run every frame. /\x2f /\x2f This is a workaround for Chrome. Don't do this on mobile, since there's much more /\x2f rendering time jitter on mobile and this causes skips. if(this._limitFramerate && !ppixiv.mobile) { let targetFramerate = ppixiv.settings.get("slideshow_framerate"); if(targetFramerate != null) { let targetDelay = 1000/targetFramerate; if(delta*1.05 < targetDelay) continue; } } lastUpdate = now; break; } delta *= this.animation.playbackRate; let newCurrentTime = this.animation.currentTime + delta; /\x2f Clamp the time to the end (this may be infinity). let timing = this.animation.effect.getComputedTiming(); let maxTime = timing.duration*timing.iterations; let finished = newCurrentTime >= maxTime; if(finished) newCurrentTime = maxTime; /\x2f Update the animation. this.animation.currentTime = newCurrentTime; /\x2f If we reached the end, run onfinish and stop. This will never happen if maxTime /\x2f is infinity. if(finished) { this._updatePlayState("finished"); this.finished.accept(); if(this.onfinish) this.onfinish(); break; } } } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/actors/direct-animation.js `), "/vview/actors/hover-with-delay.js": loadBlob("application/javascript", `/\x2f Add delays to hovering and unhovering. The class "hover" will be set when the mouse /\x2f is over the element (equivalent to the :hover selector), with a given delay before the /\x2f state changes. /\x2f /\x2f This is used when hovering the top bar when in ui-on-hover mode, to delay the transition /\x2f before the UI disappears. transition-delay isn't useful for this, since it causes weird /\x2f hitches when the mouse enters and leaves the area quickly. import Actor from '/vview/actors/actor.js'; import { helpers } from '/vview/misc/helpers.js'; export default class HoverWithDelay extends Actor { constructor({ parent, element, enterDelay=0, exitDelay=0, }={}) { super({ parent }); this.element = element; this.enterDelay = enterDelay * 1000.0; this.exitDelay = exitDelay * 1000.0; this.timer = -1; this.pendingHover = null; element.addEventListener("mouseenter", (e) => this.onHoverChanged(true), this._signal); element.addEventListener("mouseleave", (e) => this.onHoverChanged(false), this._signal); } onHoverChanged(hovering) { /\x2f If we already have this event queued, just let it continue. if(this.pendingHover != null && this.pendingHover == hovering) return; /\x2f If the opposite event is pending, cancel it. if(this.hoverTimeout != null) { realClearTimeout(this.hoverTimeout); this.hoverTimeout = null; } this.realHoverState = hovering; this.pendingHover = hovering; let delay = hovering? this.enterDelay:this.exitDelay; this.hoverTimeout = realSetTimeout(() => { this.pendingHover = null; this.hoverTimeout = null; helpers.html.setClass(this.element, "hover", this.realHoverState); }, delay); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/actors/hover-with-delay.js `), "/vview/actors/isolated-tap-handler.js": loadBlob("application/javascript", ` /\x2f Detect isolated taps: single taps that don't become double-taps or drags, or /\x2f are handled by something else. This is a common mobile UI, but there's no /\x2f event for it. /\x2f /\x2f We watch for taps where we see the release and no other events for our duration. /\x2f This means the press is released quickly (not a long press or one where the user /\x2f hesitated intenting to drag), there wasn't another press to make it a double-tap, /\x2f and where none of the events are handled by anything else. /\x2f /\x2f We have to make assumptions about how long the double-click delay is. If we /\x2f guess too short we'll signal when a double-click could actually still happen, /\x2f and if we guess too long we'll be less responsive. The delay should be adjusted /\x2f depending on how much of a problem false positives are. For displaying the /\x2f illust menu this can be a bit lower, since it'll just display the menu which will /\x2f be immediately hidden by the second tap. /\x2f /\x2f This doesn't currently detect if the tap was on something that had a default /\x2f action, like a link, since we only use this for taps on the image view. import Actor from '/vview/actors/actor.js'; export default class IsolatedTapHandler extends Actor { static handlers = new Set(); /\x2f If any running IsolatedTapHandler saw a pointerdown and is about to run, /\x2f cancel it. This can be used to prevent isolated taps in places where it's /\x2f hard to access a pointer event related to it. static preventTaps() { for(let handler of IsolatedTapHandler.handlers) { handler._clearPresses(); } } constructor({ parent, node, callback, delay=350 }={}) { super({parent}); this._node = node; this._callback = callback; this._lastPointerDownAt = -99999; this._delay = delay; this._timeoutId = -1; this._pressed = false; this._allPresses = new Set(); IsolatedTapHandler.handlers.add(this); this.shutdownSignal.addEventListener("abort", () => IsolatedTapHandler.handlers.delete(this)); this._eventNamesDuringTouch = ["pointerup", "pointercancel", "pointermove", "blur", "dblclick"]; this._node.addEventListener("pointerdown", this._handleEvent, this._signal); } /\x2f Start listening to events that we only listen to during a press, since these have to go /\x2f on window. _registerEvents() { for(let type of this._eventNamesDuringTouch) window.addEventListener(type, this._handleEvent, { capture: true, ...this._signal }); } _unregisterEvents() { for(let type of this._eventNamesDuringTouch) this._node.removeEventListener(type, this._handleEvent, { capture: true }); } _handleEvent = (e) => { if(e.type == "blur") { /\x2f iOS sometimes doesn't cancel events properly on gestures, so discard any press on /\x2f blur and clear our press list. this._clearPresses(); return; } /\x2f Keep track of pointer events, since they forgot to include it on pointer events. /\x2f We won't know if there are multitouch events on other nodes. if(e.type == "pointerdown") this._allPresses.add(e.pointerId); else if(e.type == "pointerup" || e.type == "pointercancel") this._allPresses.delete(e.pointerId); /\x2f If we see pointer events for a different pointer, unqueue our event. if(this._pressed && e.pointerId != this._pressEvent.pointerId) { /\x2f console.log("Cancelling for multitouch"); this._unqueueEvent(); return; } /\x2f Cancel if we see a dblclick. This is important because iOS doesn't always send pointer /\x2f events for double-taps. if(e.type == "dblclick") { /\x2f console.log("Cancelling for dblclick"); this._unqueueEvent(); } if(e.type == "pointercancel") { this._clearPresses(); return; } if(e.type == "pointerdown") { /\x2f If this isn't the first touch on the element, ignore it. if(this._allPresses.size > 1) { /\x2f console.log("Ignoring press during multitouch"); return; } /\x2f Start watching the other events. this._registerEvents(); this._unqueueEvent(); let now = Date.now(); let timeSinceLastPress = now - this._lastPointerDownAt; this._lastPointerDownAt = Date.now(); if(timeSinceLastPress < this._delay) { /\x2f If we get a pointerdown quickly after another, this is just cancelling any queued /\x2f event that we started, since this means it isn't an isolated tap. /\x2f console.log("Cancelled"); return; } /\x2f If this is a pointerdown and we haven't seen another pointerdown in at least /\x2f our delay, start a new potential press. /\x2f console.log("Starting pointer monitoring"); this._checkEvents = []; this._pressed = true; /\x2f Keep the initial press event so we can pass it to the callback. this._pressEvent = e; this._queueEvent(); } /\x2f Any pointer movement cancels the tap. Mobile browsers already threshold pointer movement, /\x2f so we don't need to do it. if(e.type == "pointermove") { this._unqueueEvent(); return; } if(e.type == "pointerup") { this._unregisterEvents(); this._pressed = false; } /\x2f We need to know if any of these events are handled, even if they're in event handlers /\x2f that trigger after us. Just keep a list of all of them and we'll check them when the /\x2f timer expires. this._checkEvents.push(e); } _clearPresses() { this._unqueueEvent(); this._allPresses.clear(); this._pressed = false; } _queueEvent = () => { if(this._timeoutId != -1) return; this._timeoutId = realSetTimeout(() => { if(this.hasShutdown) return; this._timeoutId = -1; /\x2f If the press is still held, this isn't an isolated press. if(this._pressed) { /\x2f console.log("Held too long"); return; } /\x2f If any pointer event for this press was cancelled, that means something handled /\x2f something about the press, so don't use it. for(let event of this._checkEvents) { if(event.defaultPrevented || event.cancelBubble) { /\x2f console.log("Press was handled:", event); return; } /\x2f If partiallyHandled is set, it means something was done with the event /\x2f that didn't want to cancel the event, but does want to prevent us from /\x2f treating it as an isolated tap. For example, if ClickOutsideListener /\x2f triggers to close the viewer menu it won't prevent the event, but we don't /\x2f want it to be an isolated tap. if(event.partiallyHandled) { /\x2f console.log("Press handled by ClickOutsideListener"); return; } } /\x2f This is a strange workaround for a strange iOS Safari bug. If the tap is in the /\x2f same place as the avatar widget, it receives a click when it becomes visible. It's /\x2f receiving a click event for a touchup that happened about 300ms earlier while it /\x2f wasn't even visible. /\x2f /\x2f This workaround is even more mysterious: calling the callback inside another timer /\x2f with no delay stops it from happening. /\x2f /\x2f Grab pressEvent and re-check hasShutdown after the sleep. let pressEvent = this._pressEvent; this._pressEvent = null; realSetTimeout(() => { if(this.hasShutdown) return; this._callback(pressEvent); }, 0); }, this._delay); } _unqueueEvent = () => { if(this._timeoutId == -1) return; realClearTimeout(this._timeoutId); this._timeoutId = -1; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/actors/isolated-tap-handler.js `), "/vview/actors/pointer-listener.js": loadBlob("application/javascript", `/\x2f The pointer API is sadistically awful. Only the first pointer press is sent by pointerdown. /\x2f To get others, you have to register pointermove and get spammed with all mouse movement. /\x2f You have to register pointermove when a button is pressed in order to see other buttons /\x2f without keeping a pointermove event running all the time. You also have to use e.buttons /\x2f instead of e.button, because pointermove doesn't tell you what buttons changed, making e.button /\x2f meaningless. /\x2f /\x2f Who designed this? This isn't some ancient IE6 legacy API. How do you screw up a mouse /\x2f event API this badly? import { helpers } from '/vview/misc/helpers.js'; export default class PointerListener { /\x2f The global handler is used to track button presses and mouse movement globally, /\x2f primarily to implement PointerListener.check(). /\x2f The latest mouse position seen by installGlobalHandler. static latestMousePagePosition = [window.innerWidth/2, window.innerHeight/2]; static latestMouseClientPosition = [window.innerWidth/2, window.innerHeight/2]; static pointerType = "mouse"; static _buttons = 0; static _buttonPointerIds = new Map(); static installGlobalHandler() { window.addEventListener("pointermove", (e) => { PointerListener.latestMousePagePosition = [e.pageX, e.pageY]; PointerListener.latestMouseClientPosition = [e.clientX, e.clientY]; this.pointerType = e.pointerType; }, { passive: true, capture: true }); new PointerListener({ element: window, buttonMask: 0xFFFF, /\x2f everything capture: true, callback: (e) => { if(e.pressed) { PointerListener._buttons |= 1 << e.mouseButton; PointerListener._buttonPointerIds.set(e.mouseButton, e.pointerId); } else { PointerListener._buttons &= ~(1 << e.mouseButton); PointerListener._buttonPointerIds.delete(e.mouseButton); } } }); } /\x2f callback(event) will be called each time buttons change. The event will be the event /\x2f that actually triggered the state change, and can be preventDefaulted, etc. /\x2f /\x2f To disable, include {signal: AbortSignal} in options. constructor({element, callback, buttonMask=1, ...options}={}) { this.element = element; this.buttonMask = buttonMask; this._pointermoveRegistered = false; this.buttonsDown = 0; this.callback = callback; this._eventOptions = options; let handlingRightClick = (buttonMask & 2) != 0; this._blockingContextMenuUntilTimer = false; if(handlingRightClick) window.addEventListener("contextmenu", this.oncontextmenu, this._eventOptions); if(options.signal) { options.signal.addEventListener("abort", (e) => { /\x2f If we have a blockContextmenuTimer timer running when we're cancelled, remove it. if(this.blockContextmenuTimer != null) realClearTimeout(this.blockContextmenuTimer); }); } this.element.addEventListener("pointerdown", this.onpointerevent, this._eventOptions); this.element.addEventListener("simulatedpointerdown", this.onpointerevent, this._eventOptions); } /\x2f Register events that we only register while one or more buttons are pressed. /\x2f /\x2f We only register pointermove as needed, so we don't get called for every mouse /\x2f movement, and we only register pointerup as needed so we don't register a ton /\x2f of events on window. _registerEventsWhilePressed(enable) { if(this._pointermoveRegistered) return; this._pointermoveRegistered = true; this.element.addEventListener("pointermove", this.onpointermove, this._eventOptions); /\x2f These need to go on window, so if a mouse button is pressed and that causes /\x2f the element to be hidden, we still get the pointerup. window.addEventListener("pointerup", this.onpointerevent, this._eventOptions); window.addEventListener("pointercancel", this.onpointerevent, this._eventOptions); } _unregisterEventsWhilePressed(enable) { if(!this._pointermoveRegistered) return; this._pointermoveRegistered = false; this.element.removeEventListener("pointermove", this.onpointermove, this._eventOptions); window.removeEventListener("pointerup", this.onpointerevent, this._eventOptions); window.removeEventListener("pointercancel", this.onpointerevent, this._eventOptions); } _buttonChanged(buttons, event) { /\x2f We need to register pointermove to see presses past the first. if(buttons) this._registerEventsWhilePressed(); else this._unregisterEventsWhilePressed(); let oldButtonsDown = this.buttonsDown; this.buttonsDown = buttons; for(let button = 0; button < 5; ++button) { let mask = 1 << button; /\x2f Ignore this if it's not a button change for a button in our mask. if(!(mask & this.buttonMask)) continue; let wasPressed = oldButtonsDown & mask; let isPressed = this.buttonsDown & mask; if(wasPressed == isPressed) continue; /\x2f Pass the button in event.mouseButton, and whether it was pressed or released in event.pressed. /\x2f Don't use e.button, since it's in a different order than e.buttons. event.mouseButton = button; event.pressed = isPressed; this.callback(event); /\x2f Remove event.mouseButton so it doesn't appear for unrelated event listeners. delete event.mouseButton; delete event.pressed; /\x2f Right-click handling if(button == 1) { /\x2f If this is a right-click press and the user prevented the event, block the context /\x2f menu when this button is released. if(isPressed && event.defaultPrevented) this._blockContextMenuUntilRelease = true; /\x2f If this is a right-click release and the user prevented the event (or the corresponding /\x2f press earlier), block the context menu briefly. There seems to be no other way to do /\x2f this: cancelling pointerdown or pointerup don't prevent actions like they should, /\x2f contextmenu happens afterwards, and there's no way to know if a contextmenu event /\x2f is coming other than waiting for an arbitrary amount of time. if(!isPressed && (event.defaultPrevented || this._blockContextMenuUntilRelease)) { this._blockContextMenuUntilRelease = false; this._blockContextMenuUntilTimer(); } } } } onpointerevent = (e) => { this._buttonChanged(e.buttons, e); } onpointermove = (e) => { /\x2f Short-circuit processing pointermove if button is -1, which means it's just /\x2f a move (the only thing this event should even be used for). if(e.button == -1) return; this._buttonChanged(e.buttons, e); } oncontextmenu = (e) => { /\x2f Prevent oncontextmenu if RMB was pressed and cancelled, or if we're blocking /\x2f it after release. if(this._blockContextMenuUntilRelease || this._blockingContextMenuUntilTimer) { /\x2f console.log("stop context menu (waiting for timer)"); e.preventDefault(); e.stopPropagation(); } } /\x2f Block contextmenu for a while. _blockContextMenuUntilTimer() { /\x2f console.log("Waiting for timer before releasing context menu"); this._blockingContextMenuUntilTimer = true; if(this.blockContextmenuTimer != null) { realClearTimeout(this.blockContextmenuTimer); this.blockContextmenuTimer = null; } this.blockContextmenuTimer = realSetTimeout(() => { this.blockContextmenuTimer = null; /\x2f console.log("Releasing context menu after timer"); this._blockingContextMenuUntilTimer = false; }, 50); } /\x2f Check if any buttons are pressed that were missed while the element wasn't visible. /\x2f /\x2f This can be used if the element becomes visible, and we want to see any presses /\x2f already happening that are over the element. /\x2f /\x2f This requires installGlobalHandler. checkMissedClicks() { /\x2f If no buttons are pressed that this listener cares about, stop. if(!(this.buttonMask & PointerListener.buttons)) return; /\x2f See if the cursor is over our element. let nodeUnderCursor = document.elementFromPoint(PointerListener.latestMouseClientPosition[0], PointerListener.latestMouseClientPosition[1]); if(nodeUnderCursor == null || !helpers.html.isAbove(this.element, nodeUnderCursor)) return; /\x2f Simulate a pointerdown on this element for each button that's down, so we can /\x2f send the corresponding pointerId for each button. for(let button = 0; button < 8; ++button) { /\x2f Skip this button if it's not down. let mask = 1 << button; if(!(mask & PointerListener.buttons)) continue; /\x2f Add this button's mask to the listener's last seen mask, so it only sees this /\x2f button being added. This way, each button event is sent with the correct /\x2f pointerId. let newButtonMask = this.buttonsDown; newButtonMask |= mask; let e = new MouseEvent("simulatedpointerdown", { buttons: newButtonMask, pageX: PointerListener.latestMousePagePosition[0], pageY: PointerListener.latestMousePagePosition[1], clientX: PointerListener.latestMousePagePosition[0], clientY: PointerListener.latestMousePagePosition[1], timestamp: performance.now(), }); e.pointerId = PointerListener._buttonPointerIds.get(button); this.element.dispatchEvent(e); } } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/actors/pointer-listener.js `), "/vview/actors/property-animation.js": loadBlob("application/javascript", `/\x2f Animate a single property on a node. /\x2f /\x2f This allows setting a property (usually a CSS --var), and animating it towards a given /\x2f value. /\x2f /\x2f This doesn't use Animation. They still don't work with CSS vars, and Animation has too /\x2f many quirks to bother with for this. import Bezier2D from '/vview/util/bezier.js'; import Actor from '/vview/actors/actor.js'; import { helpers } from '/vview/misc/helpers.js'; export default class PropertyAnimation extends Actor { constructor({ /\x2f The node containing the property to animate. This can be an array of multiple nodes, /\x2f which will all be set. node, property, /\x2f The position of the animation is always 0-1. The property value is scaled to /\x2f this range: propertyStart=0, propertyEnd=1, /\x2f If play() is called, this is called after the animation completes. onanimationfinished, /\x2f This is called when this.position changes, including during animations. onchange=() => { }, ...options }={}) { super({...options}); if(!(node instanceof Array)) node = [node]; this.node = node; this.onanimationfinished = onanimationfinished; this.onchange = onchange; this.state = "stopped"; this.property = property; this.propertyStart = propertyStart; this.propertyEnd = propertyEnd; } shutdown() { this.stop(); super.shutdown(); } /\x2f When not animating, return the current offset. /\x2f /\x2f If an animation is running, this will return the static offset, ignoring the animation. get position() { /\x2f static_animation is scaled to 0-1. Scale it back to the caller's range. return this._position; } /\x2f Set the current position. If this is called while animating, the animation will be /\x2f stopped. set position(offset) { /\x2f We don't currently set the position while animating, so flag it as a bug for now. if(this.playing) throw new Error("Animation is running"); this._setPosition(offset); } _setPosition(position) { let oldPosition = this._position; let oldValue = this._propertyValue; this._position = position; let value = this._propertyValue = this.propertyValueForPosition(position); for(let node of this.node) node.style.setProperty(this.property, value); /\x2f Call onchange with the old and new values. Note that oldValue and oldPosition /\x2f are null on the first call. this.onchange({position, value, oldPosition, oldValue}); } /\x2f Return the value of the output property for the given 0-1 position. propertyValueForPosition(position) { return helpers.math.scale(position, 0, 1, this.propertyStart, this.propertyEnd); } /\x2f Return the current value of the property. get currentPropertyValue() { return this.propertyValueForPosition(this._position); } /\x2f Return true if an animation is active. get playing() { return this._playToken != null; } /\x2f Play the animation from the current position to endPosition, replacing any running animation. async play({endPosition=1, easing="ease-in-out", duration=300}={}) { /\x2f This is just for convenience, so the caller can tell which way an animation is going. this.animatingTowards = endPosition; /\x2f Create a new token. If another play() call takes over the animation or we're stopped, this /\x2f will change and we'll stop animating. let token = this._playToken = new Object(); /\x2f Get the easing curve. let curve = easing instanceof Bezier2D? easing:Bezier2D.curve(easing); if(curve == null) throw new Error(\`Unknown easing curve: \${easing}\`); let startPosition = this._position; let startTime = Date.now(); while(1) { await helpers.other.vsync(); /\x2f Stop if the animation state changed while we were async. if(token !== this._playToken) return; /\x2f The position through this animation, from 0 to 1: let offset = (Date.now() - startTime) / duration; offset = helpers.math.clamp(offset, 0, 1); /\x2f Apply easing. let offset_with_easing = curve.evaluate(offset); /\x2f Update the animation. Snap to the start and end positions to remove rounding error. let newPosition = helpers.math.scale(offset_with_easing, 0, 1, startPosition, endPosition); if(Math.abs(newPosition - startPosition) < 0.00001) newPosition = startPosition; if(Math.abs(newPosition - endPosition) < 0.00001) newPosition = endPosition; this._setPosition(newPosition); if(offset == 1) break; } this.animatingTowards = null; this._playToken = null; this.onanimationfinished(this); } /\x2f Stop the animation if it's running. stop() { /\x2f Clearing _playToken will stop any running play() loop. this._playToken = null; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/actors/property-animation.js `), "/vview/actors/scroll-listener.js": loadBlob("application/javascript", `/\x2f Watch for scrolls on a scroller, and call onchange when the user scrolls up or down. This /\x2f allows for an approximation of iOS's behavior of hiding navigation bars while scrolling down, /\x2f then showing them if you scroll up. /\x2f /\x2f We can't mimic the behavior completely. iOS hides navigation bars as you scroll, and then /\x2f snaps to fully open or closed when you release the scroll. There's no way to tell when a touch /\x2f scroll ends, since scrolls cancel the touch and take it over completely. No event is sent when /\x2f the touch is released or when momentum scrolls settle. Instead, we just watch for scrolling /\x2f a minimum amount in the same direction, This at least prevents the UI from appearing and disappearing /\x2f too rapidly if the scroller is moved up and down quickly. import Actor from '/vview/actors/actor.js'; import FlingVelocity from '/vview/util/fling-velocity.js'; import { helpers } from '/vview/misc/helpers.js'; export default class ScrollListener extends Actor { constructor({ scroller, /\x2f The minimum amount of movement in the same direction before it's treated as /\x2f a direction change. threshold=50, /\x2f If not null, the threshold when dragging up. This allows dragging down to /\x2f hide the UI to have a longer threshold than dragging up to display it. If this /\x2f is null, threshold is used. thresholdUp=10, /\x2f The initial value of scrolledForwards. This is also the value used if it's not /\x2f possible to scroll. defaultValue=false, /\x2f If set, we always consider the scroller dragged up until we're past the height of /\x2f this node. This allows keeping sticky UI visible until we've scrolled far enough /\x2f that the content below it will fill its space when it's hidden. stickyUiNode=null, /\x2f This is called when this.direction changes. onchange = (listener) => { }, ...options }) { super({ ...options }); this._scroller = scroller; this._threshold = threshold; this._thresholdUp = thresholdUp ?? threshold; this._onchange = onchange; this._lastScrollY = 0; this._defaultValue = defaultValue; this._scrolledForwards = false; this._stickyUiNode = stickyUiNode; this._scroller.addEventListener("scroll", () => this._refreshAfterScroll(), this._signal); this._recentPointerMovement = new FlingVelocity({ samplePeriod: 1 }); /\x2f If we've been given a sticky UI node, refresh if its height changes. if(this._stickyUiNode) { this._resizeObserver = new ResizeObserver(() => { this._refreshAfterScroll(); }); this.shutdownSignal.addEventListener("abort", () => this._resizeObserver.disconnect()); this._resizeObserver.observe(this._stickyUiNode); } /\x2f Use ScrollDimensionsListener to detect changes to scrollHeight. This is needed so if /\x2f elements are removed and the scroller becomes no longer scrollable, we reset to the default /\x2f state (usually causing the UI to be visible). Otherwise, it would be impossible to scroll /\x2f to show the UI if this happens. new ScrollDimensionsListener({ scroller, parent: this, onchange: () => { this._refreshAfterScroll({ignoreScrollHeight: true}); }, }); this.reset({callOnchange: false}); } /\x2f Reset scrolledForwards to the given direction and clear scroll history. If resetTo is null, use /\x2f the default. onchange will be called if onchange is true. reset({resetTo=null, callOnchange=true}={}) { if(resetTo == null) resetTo = this._defaultValue; /\x2f Set this direction by simulating a drag in that direction, so we only set the /\x2f direction if it would normally be possible. this._recentPointerMovement.reset(); this._recentPointerMovement.addSample({ y: resetTo? this._threshold:-this._thresholdUp }); this._updateScrolledForwards({callOnchange}); this._recentPointerMovement.reset(); } /\x2f Return true if the most recent scroll was positive (down or right), or false if it was /\x2f negative. get scrolledForwards() { return this._scrolledForwards; } get _currentScrollPosition() { /\x2f Ignore scrolls past the edge, to avoid being confused by iOS's overflow scrolling. return helpers.math.clamp(this._scroller.scrollTop, 0, this._scroller.scrollHeight-this._scroller.offsetHeight); } _refreshAfterScroll({ignoreScrollHeight=false}={}) { /\x2f If scrollHeight changed, content may have been added or removed to the scroller, so /\x2f we don't know if we've actually been scrolling up or down. Ignore a single scroll /\x2f event after the scroller changes, so we don't treat a big content change as a scroll. /\x2f Still update the result so we notice if we're now scrolled to the edge, just ignore /\x2f the scroll delta. if(ignoreScrollHeight || this._lastScrollHeight != this._scroller.scrollHeight) { /\x2f console.log("Ignoring scroll after scroller change"); this._lastScrollHeight = this._scroller.scrollHeight; this._lastScrollY = this._currentScrollPosition; } let newScrollPosition = this._currentScrollPosition; let delta = newScrollPosition - this._lastScrollY; this._lastScrollY = newScrollPosition; /\x2f If scrolling changed direction, reset motion. let { distance } = this._recentPointerMovement.getMovementInDirection("down"); if(delta > 0 != distance > 0) this._recentPointerMovement.reset(); this._recentPointerMovement.addSample({ y: delta }); this._updateScrolledForwards({callOnchange: true}); } /\x2f Update this._scrolledForwards after movement. _updateScrolledForwards({callOnchange}) { let newScrollTop = this._currentScrollPosition; let newScrollBottom = newScrollTop + this._scroller.offsetHeight; /\x2f If we've moved far enough in either direction, set it as the scrolling direction. let scrolledForwards = this._scrolledForwards; let { distance } = this._recentPointerMovement.getMovementInDirection("down"); if(distance <= -this._thresholdUp) scrolledForwards = false; else if(Math.abs(distance) >= this._threshold) scrolledForwards = true; /\x2f If we're at the very top or very bottom, the user can't scroll any further to reach /\x2f the threshold, so force the direction to up or down. This also keeps the navigation /\x2f bar hidden if we're at the bottom, so it doesn't overlap content. if(newScrollTop == 0) scrolledForwards = false; else if(newScrollBottom >= this._scroller.scrollHeight - 1) scrolledForwards = true; if(this._stickyUiNode) { if(newScrollTop < this._stickyUiNode.offsetHeight) scrolledForwards = false; } /\x2f If it's not possible to scroll the scroller, always use the default. if(!this._canScroll) scrolledForwards = this._defaultValue; if(this._scrolledForwards == scrolledForwards) return; /\x2f Update the scroll direction. this._scrolledForwards = scrolledForwards; if(callOnchange) this._onchange(this); } /\x2f Return true if we think it's possible to move the scroller, ignoring overscroll. get _canScroll() { return this._scroller.scrollHeight > this._scroller.offsetHeight; } } /\x2f Call onchange when a node has children added or removed. /\x2f /\x2f Treat children of display: contents nodes as direct children of the node. They have no /\x2f layout of their own, and we're doing this to track resizes of the layout children of a /\x2f node. If we don't do this, we won't see scroller size changes inside display: contents /\x2f nodes directly inside the scroller. class ImmediateChildrenListener extends Actor { constructor({ root, onchange, ...options }={}) { super({ ...options }); this._onchange = onchange; this._watching = new Set(); this._mutationObserver = new MutationObserver((mutations) => { for(let mutation of mutations) { for(let node of mutation.addedNodes) this._nodeAdded(node, { isRoot: false }); for(let node of mutation.removedNodes) this._nodeRemoved(node); } }); this.shutdownSignal.addEventListener("abort", () => this._mutationObserver.disconnect()); this._nodeAdded(root, { isRoot: true }); } /\x2f A node we're watching had a child added (or we're adding the root). _nodeAdded(node, { isRoot }) { if(!isRoot) this._onchange({node, added: true}); /\x2f If an added node is display: contents, it doesn't have layout, and we need /\x2f to watch its children in the same way we're watching the root. We don't /\x2f support display changing to or from contents. let isContents = getComputedStyle(node).display == "contents"; if(isRoot || isContents) { console.assert(!this._watching.has(node)); this._watching.add(node); this._mutationObserver.observe(node, { childList: true }); for(let child of node.children) this._nodeAdded(child, {isRoot: false}); } } /\x2f A node we're watching had a child removed. _nodeRemoved(node) { this._onchange({node, added: false}); let isContents = getComputedStyle(node).display == "contents"; if(isRoot || isContents) { console.assert(this._watching.has(node)); this._watching.remove(node); this._mutationObserver.unobserve(node, { childList: true }); for(let child of node.children) { this._nodeRemoved(child); } } } } /\x2f There seems to be no quick way to tell when scrollHeight or scrollWidth change on a /\x2f scroller. We have to watch for resizes on all children. class ScrollDimensionsListener extends Actor { constructor({ scroller, onchange = (listener) => { }, ...options }={}) { super({ ...options }); this.onchange = onchange; /\x2f The ResizeObserver watches for size changes to children which could cause the scroll /\x2f size to change. this._resizeObserver = new ResizeObserver(() => { this.onchange(this); }); this.shutdownSignal.addEventListener("abort", () => this._resizeObserver.disconnect()); this._childrenListener = new ImmediateChildrenListener({ parent: this, root: scroller, onchange: ({ node, added }) => { if(added) this._resizeObserver.observe(node); else this._resizeObserver.unobserve(node); } }); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/actors/scroll-listener.js `), "/vview/actors/stop-animation-after.js": loadBlob("application/javascript", `/\x2f Gradually slow down and stop the given CSS animation after a delay, resuming it /\x2f if the mouse is moved. import { helpers } from '/vview/misc/helpers.js'; export default class StopAnimationAfter { constructor(animation, delay, duration, vertical) { this.animation = animation; this.delay = delay; this.duration = duration; this.vertical = vertical; this.abort = new AbortController(); this.run(); } async run() { /\x2f We'll keep the animation running as long as we've been active within the delay /\x2f period. let last_activity_at = Date.now() / 1000; let onmove = (e) => { last_activity_at = Date.now() / 1000; }; window.addEventListener("mousemove", onmove, { passive: true, }); try { /\x2f This is used for thumbnail animations. We want the animation to end at a /\x2f natural place: at the top for vertical panning, or in the middle for horizontal /\x2f panning. /\x2f /\x2f Animations are async, so we can't control their speed precisely, but it's close /\x2f enough that we don't need to worry about it here. /\x2f /\x2f Both animations last 4 seconds. At a multiple of 4 seconds, the vertical animation /\x2f is at the top and the horizontal animation is centered, which is where we want them /\x2f to finish. The vertical animation's built-in deceleration is also at the end, so for /\x2f those we can simply stop the animation when it reaches a multiple of 4. /\x2f /\x2f Horizontal animations decelerate at the edges rather than at the end, so we need to /\x2f decelerate these by reducing playbackRate. /\x2f How long the deceleration lasts. We don't need to decelerate vertical animations, so /\x2f use a small value for those. const duration = this.vertical? 0.001:0.3; /\x2f We want the animation to stop with currentTime equal to this: let stop_at_animation_time = null; while(1) { let success = await helpers.other.vsync({signal: this.abort.signal}); if(!success) break; let now = Date.now() / 1000; let stopping = now >= last_activity_at + this.delay; if(!stopping) { /\x2f If the mouse has moved recently, set the animation to full speed. We don't /\x2f accelerate back to speed. stop_at_animation_time = null; this.animation.playbackRate = 1; continue; } /\x2f We're stopping, since the mouse hasn't moved in a while. Figure out when we want /\x2f the animation to actually stop if we haven't already. if(stop_at_animation_time == null) { stop_at_animation_time = this.animation.currentTime / 1000 + 0.0001; stop_at_animation_time = Math.ceil(stop_at_animation_time / 4) * 4; /\x2f round up to next multiple of 4 } let animation_time = this.animation.currentTime/1000; /\x2f The amount of animation time left, ignoring playbackSpeed: let animation_time_left = stop_at_animation_time - animation_time; if(animation_time_left > duration) { this.animation.playbackRate = 1; continue; } if(animation_time_left <= 0.001) { this.animation.playbackRate = 0; continue; } /\x2f We want to decelerate smoothly, reaching a velocity of zero when animation_time_left /\x2f reaches 0. Just estimate it by decreasing the time left linearly. this.animation.playbackRate = animation_time_left / duration; } } finally { window.removeEventListener("mousemove", onmove); } } /\x2f Stop affecting the animation and return it to full speed. shutdown() { this.abort.abort(); this.animation.playbackRate = 1; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/actors/stop-animation-after.js `), "/vview/actors/touch-listener.js": loadBlob("application/javascript", `/\x2f PointerListener is complicated because it deals with overlapping LMB and RMB presses, /\x2f and a bunch of browser weirdness around context menus and other things that a lot of /\x2f UI doesn't need. TouchListener is a simpler interface that only listens for left-clicks. /\x2f Touch inputs will see multitouch if the multi flag is true. import Actor from '/vview/actors/actor.js'; export default class TouchListener extends Actor { /\x2f callback(event) will be called each time buttons change. The event will be the event /\x2f that actually triggered the state change, and can be preventDefaulted, etc. constructor({ element, parent, callback, multi=false, }={}) { super({ parent }); this.element = element; this.callback = callback; this.multi = multi; this.pressedPointerIds = new Set(); this.element.addEventListener("pointerdown", this.onpointerevent, this._signal); } /\x2f Register events that we only register while one or more buttons are pressed. /\x2f /\x2f We only register pointermove as needed, so we don't get called for every mouse /\x2f movement, and we only register pointerup as needed so we don't register a ton /\x2f of events on window. _updateEventsWhilePressed() { if(this.pressedPointerIds.size > 0) { /\x2f These need to go on window, so if a mouse button is pressed and that causes /\x2f the element to be hidden, we still get the pointerup. window.addEventListener("pointerup", this.onpointerevent, { capture: true, ...this._signal }); window.addEventListener("pointercancel", this.onpointerevent, { capture: true, ...this._signal }); window.addEventListener("blur", this.onblur, this._signal); } else { window.removeEventListener("pointerup", this.onpointerevent, { capture: true }); window.removeEventListener("pointercancel", this.onpointerevent, { capture: true }); window.removeEventListener("blur", this.onblur); } } onblur = (event) => { /\x2f Work around an iOS Safari bug: horizontal navigation drags don't always cancel pointer /\x2f events. It sends pointerdown, but then never sends pointerup or pointercancel when it /\x2f takes over the drag, so it looks like the touch stays pressed forever. This seems /\x2f to happen on forwards navigation but not back. /\x2f /\x2f If this happens, we get a blur event, so if we get a blur event and we were still pressed, /\x2f send an emulated pointercancel event to end the drag. for(let pointerId of this.pressedPointerIds) { console.warn(\`window.blur for \${pointerId} fired without a pointer event being cancelled, simulating it\`); this.onpointerevent(new PointerEvent("pointercancel", { pointerId, button: 0, buttons: 0, })); } } onpointerevent = (event) => { let isPressed = event.type == "pointerdown"; /\x2f Stop if this doesn't change the state of this pointer. if(this.pressedPointerIds.has(event.pointerId) == isPressed) return; /\x2f If this is a multitouch and multi isn't enabled, ignore it. if(!this.multi && isPressed && this.pressedPointerIds.size > 0) return; /\x2f We need to register pointermove to see presses past the first. if(isPressed) this.pressedPointerIds.add(event.pointerId); else this.pressedPointerIds.delete(event.pointerId); this._updateEventsWhilePressed(); event.pressed = isPressed; this.callback(event); delete event.pressed; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/actors/touch-listener.js `), "/vview/actors/widget-dragger.js": loadBlob("application/javascript", `import PropertyAnimation from '/vview/actors/property-animation.js'; import Bezier2D from '/vview/util/bezier.js'; import FlingVelocity from '/vview/util/fling-velocity.js'; import DragHandler from '/vview/misc/drag-handler.js'; import ClickOutsideListener from '/vview/widgets/click-outside-listener.js'; import Actor from '/vview/actors/actor.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f A simpler interface for allowing a widget to be dragged open or closed. export default class WidgetDragger extends Actor { constructor({ name="widget-dragger", /\x2f for diagnostics /\x2f The node that will be animated by the drag. nodes, /\x2f The node to listen for drags on: dragNode, /\x2f The drag distance the drag that corresponds to a full transition from closed to /\x2f open. This can be a number, or a function that returns a number. size, animatedProperty=null, animatedPropertyInverted=false, /\x2f If set, this is an array of nodes inside the dragger, and clicks outside of this /\x2f list while visible will cause the dragger to hide. closeIfOutside=null, /\x2f This is called before a drag starts. If false is returned, the drag will be ignored. confirmDrag = () => true, /\x2f Callbacks /\x2f /\x2f onactive /\x2f ondragstart <-> ondragend User dragging started or stopped /\x2f onanimationstart <-> onanimationfinished Animation such as a fling started or stopped /\x2f onbeforeshown <-> onafterhidden Visibility changed /\x2f oninactive onactive = () => { }, oninactive = () => { }, ondragstart = () => { }, ondragend = () => { }, onanimationstart = () => { }, onanimationfinished = () => { }, onbeforeshown = () => { }, onafterhidden = () => { }, /\x2f This is called if we were cancelled by another dragger starting first. oncancelled, /\x2f This is called on any state change (the value of this.state has changed). onstatechange = () => { }, /\x2f Whether the widget is initially visible. visible=false, /\x2f The drag direction that will open the widget: up, down, left or right. direction="down", /\x2f Animation properties. These are the same for all animated nodes. duration=150, startOffset=0, endOffset=1, ...options }={}) { super(options); this._visible = visible; this.nodes = nodes; this.onactive = onactive; this.oninactive = oninactive; this.ondragstart = ondragstart; this.ondragend = ondragend; this.onanimationstart = onanimationstart; this.onanimationfinished = onanimationfinished; this.onbeforeshown = onbeforeshown; this.onafterhidden = onafterhidden; this.onstatechange = onstatechange; this.confirmDrag = confirmDrag; this.animatedProperty = animatedProperty; this.animatedPropertyInverted = animatedPropertyInverted; this.closeIfOutside = closeIfOutside; this.duration = duration; this.startOffset = startOffset; this.endOffset = endOffset; this._state = "idle"; this._runningNonInterruptibleAnimation = false; if(!(this.duration instanceof Function)) this.duration = () => duration; if(direction != "up" && direction != "down" && direction != "left" && direction != "right") throw new Error(\`Invalid drag direction: \${direction}\`); let vertical = direction == "up" || direction == "down"; let reversed = direction == "left" || direction == "up"; /\x2f Create the velocity tracker used to detect flings. this._recentPointerMovement = new FlingVelocity({ samplePeriod: 0.150 }); /\x2f Create the velocity tracker for the speed the animated property is changing. this._recentValueMovement = new FlingVelocity({ samplePeriod: 0.150 }); let propertyStart = animatedPropertyInverted? 1:0; let propertyEnd = animatedPropertyInverted? 0:1; /\x2f Create the animation. this._dragAnimation = new PropertyAnimation({ parent: this, node: this.nodes, property: this.animatedProperty, propertyStart, propertyEnd, startOffset: this.startOffset, endOffset: this.endOffset, onanimationfinished: (anim) => { /\x2f Update visibility if the animation we finished put us at 0. if(anim.position < 0.00001) this._setVisible(false); /\x2f If a drag was left active during the animation, cancel it before returning to idle. this.dragger.cancelDrag(); /\x2f When an animation finishes normally, we're no longer doing anything, so /\x2f go back to inactive. this._setState("idle"); }, onchange: ({value, oldValue}) => { if(oldValue == null) return; let delta = Math.abs(value - oldValue); this._recentValueMovement.addSample({ x: delta }); }, }); this._dragAnimation.position = visible? 1:0; this.dragger = new DragHandler({ parent: this, name, element: dragNode, oncancelled, ondragstart: ({event}) => { /\x2f If this is a horizontal dragger, see if we should ignore this drag because /\x2f it might trigger iOS navigation. if(!vertical && helpers.shouldIgnoreHorizontalDrag(event)) return false; /\x2f Only accept this drag if the axis of the drag matches ours. let dragIsVertical = Math.abs(event.movementY) > Math.abs(event.movementX); if(vertical != dragIsVertical) return false; let movement = vertical? event.movementY:event.movementX; if(reversed) movement *= -1; /\x2f If the drag has nowhere to go in this direction, don't accept it, so other draggers /\x2f see it instead. let towardsShown = movement > 0; if(towardsShown && this.position == 1) return false; if(!towardsShown && this.position == 0) return false; if(this._runningNonInterruptibleAnimation) { console.log("Not dragging because a non-interruptible animation is in progress"); return false; } if(!this.confirmDrag({event})) return false; /\x2f Stop any running animation. this._dragAnimation.stop(); this._recentPointerMovement.reset(); this._setState("dragging"); /\x2f A drag is starting. Send onbeforeshown if we weren't visible, since we /\x2f might be about to make the widget visible. this._setVisible(true); /\x2f Remember the position we started at. This is only used so we can return to it if /\x2f the drag is cancelled. this._dragStartedAt = this.position; return true; }, ondrag: ({event, first}) => { if(this._runningNonInterruptibleAnimation) { console.log("Not dragging because a non-interruptible animation is in progress"); return false; } /\x2f If we're animating, show() or hide() was called during a drag. This doesn't stop /\x2f the drag, but we're in the animating state while this happens. Since we saw another /\x2f drag movement, cancel the animation and return to dragging. if(this._state == "animating") { console.log("animation interrupted by drag"); this._dragAnimation.stop(); this._setState("dragging"); } if(this._state != "dragging") this._logStateChanges(\`Expected dragging, in \${this._state}\`); /\x2f Drags should always be in the dragging state, and won't change state. console.assert(this._state == "dragging", this._state); this._recentPointerMovement.addSample({ x: event.movementX, y: event.movementY }); /\x2f The first movement is thresholded by the browser, and counts towards fling velocity /\x2f but doesn't actually move the widget. if(first) return; /\x2f If show() or hide() was called during a fling and the user dragged again, we're interrupting /\x2f the animation to continue the drag, so stop the drag. this._dragAnimation.stop(); let pos = this._dragAnimation.position; let movement = vertical? event.movementY:event.movementX; if(reversed) movement *= -1; let actualSize = size; if(actualSize instanceof Function) actualSize = actualSize(); pos += movement / actualSize; pos = helpers.math.clamp(pos, this.startOffset, this.endOffset); this._dragAnimation.position = pos; }, /\x2f When a drag ends, we'll always call either show() or hide(), which will either start /\x2f an animation or put us in the inactive state. ondragend: ({cancel}) => { /\x2f If the drag was cancelled, return to the open or close state we were in at the /\x2f start. This is mostly important for ScreenIllustDragToExit, so a drag up on iOS /\x2f that triggers system navigation and cancels our drag undoes any small drag instead /\x2f of triggering an exit. if(cancel) { if(this._dragStartedAt > 0.5) this.show(); else this.hide(); return; } /\x2f See if there was a fling. let { velocity } = this._recentPointerMovement.getMovementInDirection(direction); let threshold = 150; if(velocity > threshold) return this.show({ velocity }); else if(velocity < -threshold) return this.hide({ velocity: -velocity }); /\x2f If there hasn't been a fling recently, open or close based on how far open we are. let open = this._dragAnimation.position > 0.5; if(open) this.show({ velocity }); else this.hide({ velocity: -velocity }); }, }); } /\x2f Return the dragger state: "idle", "dragging" or "animating". This can also be /\x2f "active" while we're transitioning between states. get state() { return this._state; } get visible() { return this._visible; } get position() { return this._dragAnimation.position; } _setVisible(value) { if(this._visible == value) return; this._visible = value; if(this._visible) this.onbeforeshown(); else this.onafterhidden(); if(this.closeIfOutside) { /\x2f Create or destroy the ClickOutsideListener. if(this._visible && this._clickedOutsideListener == null) { this._clickedOutsideListener = new ClickOutsideListener(this.closeIfOutside, () => this.hide()); } else if(!this._visible && this._clickedOutsideListener != null) { this._clickedOutsideListener.shutdown(); this._clickedOutsideListener = null; } } } /\x2f Animate to the fully shown state. If given, velocity is the drag speed that caused this. /\x2f /\x2f If a drag is in progress, it'll continue, and cancel the animation if it moves again. The /\x2f drag will be cancelled if the animation completes. /\x2f /\x2f If transition is false, jump to the new state without animating. /\x2f /\x2f If interruptible is true, this animation can be stopped by the user dragging it. If false, /\x2f drags will be ignored and the animation will always complete. show({ easing=null, transition=true, interruptible=true }={}) { this._animateTo({endPosition: 1, easing, transition, interruptible}); } /\x2f Animate to the completely hidden state. If given, velocity is the drag speed that caused this. hide({ easing=null, transition=true, interruptible=true }={}) { this._animateTo({endPosition: 0, easing, transition, interruptible}); } _animateTo({ endPosition, easing=null, transition=true, interruptible=true }={}) { if(this._runningNonInterruptibleAnimation) { console.log("Not running animation because a non-interruptible one is already in progress"); return; } /\x2f If we don't want a transition, stop any animation and just jump to this position. if(!transition) { this._dragAnimation.stop(); this._dragAnimation.position = endPosition; this._setVisible(endPosition > 0); this._setState("idle"); return; } /\x2f Stop if we're already in this state. if(this._state == "idle" && this._dragAnimation.position == endPosition) return; /\x2f Remember if the animation is interruptible. this._runningNonInterruptibleAnimation = !interruptible; /\x2f If we're already animating towards this position, just let it continue. if(this._state == "animating" && this._dragAnimation.animatingTowards == endPosition) return; /\x2f If we're animating to a visible state, mark ourselves visible. if(endPosition > 0) this._setVisible(true); let duration = this.duration(); /\x2f If no easing was specified, create an easing curve to match the current velocity /\x2f of the animated property. if(easing == null) { let propertyVelocity = this._recentValueMovement.currentVelocity.x; let propertyStart = this._dragAnimation.currentPropertyValue; let propertyEnd = this._dragAnimation.propertyValueForPosition(endPosition); /\x2f console.log("->", propertyStart, propertyEnd, propertyVelocity); easing = Bezier2D.findCurveForVelocity({ distance: Math.abs(propertyEnd - propertyStart), duration, targetVelocity: Math.abs(propertyVelocity), }).curve; } let promise = this._animationPromise = this._dragAnimation.play({endPosition, easing, duration}); this._animationPromise.then(() => { if(promise == this._animationPromise) { this._animationPromise = null; this._runningNonInterruptibleAnimation = false; } }); /\x2f Call this after starting the animation, so isAnimationPlaying and isAnimatingToShown /\x2f reflect the animation when onanimationstart is called. this._setState("animating"); } _recordStateChange(from, to) { /\x2f if(Actor.debugShutdown && !this._previousShutdownStack) /\x2f XXX { this._stateStacks ??= []; try { throw new Error(); } catch(e) { this._stateStacks.push([from, to, e.stack]); let max = 10; if(this._stateStacks.length > max) this._stateStacks.splice(this._stateStacks.length - max); } } } _logStateChanges(message) { if(!this._stateStacks) return; console.error("Error:", message); for(let [from, to, stack] of this._stateStacks) { console.log(\`From \${from} to \${to}, stack:\`); console.log(stack); } } /\x2f Set the current state: "idle", "dragging" or "animating", running the /\x2f appropriate callbacks. _setState(state, ...args) { if(state == this._state) return; /\x2f Transition back to active, ending whichever state we were in before. if(state != "idle" && this._changeState("idle", "active")) this.onactive(...args); if(state != "dragging" && this._changeState("dragging", "active")) this.ondragend(...args); if(state != "animating" && this._changeState("animating", "active")) this.onanimationfinished(...args); /\x2f Transition into the new state, beginning the new state. if(state == "dragging" && this._changeState("active", "dragging")) this.ondragstart(...args); if(state == "animating" && this._changeState("active", "animating")) this.onanimationstart(...args); if(state == "idle" && this._changeState("active", "idle")) this.oninactive(...args); } _changeState(oldState, newState) { if(this._state != oldState) return false; this._recordStateChange(this._state, newState); /\x2f console.warn(\`state change: \${oldState} -> \${newState}\`); this._state = newState; /\x2f Don't call onstatechange for active, since it's just a transition between /\x2f other states. if(newState != "active") this.onstatechange(); return true; } toggle() { if(this.visible) this.hide(); else this.show(); } /\x2f Return true if an animation (not a drag) is currently running. get isAnimationPlaying() { return this._state == "animating"; } /\x2f Return true if the current animation is towards being shown (show() was called), /\x2f or false if the current animation is towards being hidden (hide() was called). /\x2f If no animation is running, return false. get isAnimatingToShown() { if(this._state != "animating") return false; return this._dragAnimation.animatingTowards == 1; } /\x2f Return a promise that resolves when the current animation completes, or null if no animation /\x2f is running. get finished() { return this._animationPromise; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/actors/widget-dragger.js `), "/vview/misc/actions.js": loadBlob("application/javascript", `/\x2f Global actions. import { TextPrompt } from '/vview/widgets/prompts.js'; import LocalAPI from '/vview/misc/local-api.js'; import RecentBookmarkTags from '/vview/misc/recent-bookmark-tags.js'; import PixivUgoiraDownloader from '/vview/misc/pixiv-ugoira-downloader.js'; import CreateZIP from '/vview/misc/create-zip.js'; import * as Recaptcha from '/vview/util/recaptcha.js'; import { downloadPixivImage } from '/vview/util/gm-download.js'; import { helpers } from '/vview/misc/helpers.js'; export default class Actions { /\x2f Set a bookmark. Any existing bookmark will be overwritten. static async _bookmarkAddInternal(mediaId, options) { let illustId = helpers.mediaId.toIllustIdAndPage(mediaId)[0]; let mediaInfo = await ppixiv.mediaCache.getMediaInfo(mediaId, { full: false }); if(options == null) options = {}; /\x2f If auto-like is enabled, like an image when we bookmark it. if(!options.disableAutoLike) { console.log("Automatically liking image with bookmark"); Actions.likeImage(mediaId, true /* quiet */); } /\x2f Remember whether this is a new bookmark or an edit. let wasBookmarked = mediaInfo.bookmarkData != null; let request = { "illust_id": illustId, "tags": options.tags || [], "restrict": options.private? 1:0, } let result = await helpers.pixivRequest.post("/ajax/illusts/bookmarks/add", request); /\x2f If this is a new bookmark, last_bookmark_id is the new bookmark ID. /\x2f If we're editing an existing bookmark, last_bookmark_id is null and the /\x2f bookmark ID doesn't change. let newBookmarkId = result.body.last_bookmark_id; if(newBookmarkId == null) newBookmarkId = mediaInfo.bookmarkData? mediaInfo.bookmarkData.id:null; if(newBookmarkId == null) throw "Didn't get a bookmark ID"; /\x2f Store the ID of the new bookmark, so the unbookmark button works. mediaInfo.bookmarkData = { id: newBookmarkId, private: !!request.restrict, }; /\x2f Broadcast that this illust was bookmarked. This is for my own external /\x2f helper scripts. let e = new Event("bookmarked"); e.illustId = illustId; window.dispatchEvent(e); /\x2f Even if we weren't given tags, we still know that they're unset, so set tags so /\x2f we won't need to request bookmark details later. ppixiv.extraCache.updateCachedBookmarkTags(mediaId, request.tags); console.log(\`Updated bookmark data for \${mediaId}: id: \${newBookmarkId}, tags: \${request.restrict? "private":"public"} \${request.tags.join(" ")}\`); if(!wasBookmarked) { /\x2f If we have full illust data loaded, increase its bookmark count locally. let fullMediaInfo = ppixiv.mediaCache.getMediaInfoSync(mediaId); if(fullMediaInfo) fullMediaInfo.bookmarkCount++; } ppixiv.message.show( wasBookmarked? "Bookmark edited": options.private? "Bookmarked privately":"Bookmarked"); } /\x2f Create or edit a bookmark. /\x2f /\x2f Create or edit a bookmark. options can contain any of the fields tags or private. /\x2f Fields that aren't specified will be left unchanged on an existing bookmark. /\x2f /\x2f This is a headache. Pixiv only has APIs to create a new bookmark (overwriting all /\x2f existing data), except for public/private which can be changed in-place, and we need /\x2f to do an extra request to retrieve the tag list if we need it. We try to avoid /\x2f making the extra bookmark details request if possible. static async bookmarkAdd(mediaId, options) { if(helpers.mediaId.isLocal(mediaId)) return await this._localBookmarkAdd(mediaId, options); if(options == null) options = {}; /\x2f If bookmark_privately_by_default is enabled and private wasn't specified /\x2f explicitly, set it to true. if(options.private == null && ppixiv.settings.get("bookmark_privately_by_default")) options.private = true; let mediaInfo = await ppixiv.mediaCache.getMediaInfo(mediaId, { full: false }); console.log(\`Add bookmark for \${mediaId}, options:\`, options); /\x2f This is a mess, since Pixiv's APIs are all over the place. /\x2f /\x2f If the image isn't already bookmarked, just use bookmarkAdd. if(mediaInfo.bookmarkData == null) { console.log("Initial bookmark"); if(options.tags != null) RecentBookmarkTags.updateRecentBookmarkTags(options.tags); return await Actions._bookmarkAddInternal(mediaId, options); } /\x2f Special case: If we're not setting anything, then we just want this image to /\x2f be bookmarked. Since it is, just stop. if(options.tags == null && options.private == null) { console.log("Already bookmarked"); return; } /\x2f Special case: If all we're changing is the private flag, use bookmarkSetPrivate /\x2f so we don't fetch bookmark details. if(options.tags == null && options.private != null) { /\x2f If the image is already bookmarked, use bookmarkSetPrivate to edit the /\x2f existing bookmark. This won't auto-like. console.log("Only editing private field", options.private); return await Actions.bookmarkSetPrivate(mediaId, options.private); } /\x2f If we're modifying tags, we need bookmark details loaded, so we can preserve /\x2f the current privacy status. This will insert the info into mediaInfo.bookmarkData. let bookmarkTags = await ppixiv.extraCache.loadBookmarkDetails(mediaId); let bookmarkParams = { /\x2f Don't auto-like if we're editing an existing bookmark. disableAutoLike: true, }; if("private" in options) bookmarkParams.private = options.private; else bookmarkParams.private = mediaInfo.bookmarkData.private; if("tags" in options) bookmarkParams.tags = options.tags; else bookmarkParams.tags = bookmarkTags; /\x2f Only update recent tags if we're modifying tags. if(options.tags != null) { /\x2f Only add new tags to recent tags. If a bookmark has tags "a b" and is being /\x2f changed to "a b c", only add "c" to recently-used tags, so we don't bump tags /\x2f that aren't changing. for(let tag of options.tags) { let isNewTag = bookmarkTags.indexOf(tag) == -1; if(isNewTag) RecentBookmarkTags.updateRecentBookmarkTags([tag]); } } return await Actions._bookmarkAddInternal(mediaId, bookmarkParams); } static async bookmarkRemove(mediaId) { if(helpers.mediaId.isLocal(mediaId)) return await this._localBookmarkRemove(mediaId); let mediaInfo = await ppixiv.mediaCache.getMediaInfo(mediaId, { full: false }); if(mediaInfo.bookmarkData == null) { console.log("Not bookmarked"); return; } let bookmarkId = mediaInfo.bookmarkData.id; console.log("Remove bookmark", bookmarkId); let result = await helpers.pixivRequest.post("/ajax/illusts/bookmarks/remove", { bookmarkIds: [bookmarkId], }); console.log("Removing bookmark finished"); mediaInfo.bookmarkData = null; /\x2f If we have full image data loaded, update the like count locally. let fullMediaInfo = ppixiv.mediaCache.getMediaInfoSync(mediaId); if(fullMediaInfo) fullMediaInfo.bookmarkCount--; ppixiv.extraCache.updateCachedBookmarkTags(mediaId, null); ppixiv.message.show("Bookmark removed"); } static async _localBookmarkAdd(mediaId, options) { let mediaInfo = await ppixiv.mediaCache.getMediaInfo(mediaId, { full: false }); let bookmarkOptions = { }; if(options.tags != null) bookmarkOptions.tags = options.tags; /\x2f Remember whether this is a new bookmark or an edit. let wasBookmarked = mediaInfo.bookmarkData != null; let result = await LocalAPI.localPostRequest(\`/api/bookmark/add/\${mediaId}\`, { ...bookmarkOptions, }); if(!result.success) { ppixiv.message.show(\`Couldn't edit bookmark: \${result.reason}\`); return; } /\x2f Update bookmark tags and thumbnail data. ppixiv.extraCache.updateCachedBookmarkTags(mediaId, result.bookmark.tags); mediaInfo.bookmarkData = result.bookmark; let { type } = helpers.mediaId.parse(mediaId); ppixiv.message.show( wasBookmarked? "Bookmark edited": type == "folder"? "Bookmarked folder":"Bookmarked", ); } static async _localBookmarkRemove(mediaId) { let mediaInfo = await ppixiv.mediaCache.getMediaInfo(mediaId, { full: false }); if(mediaInfo.bookmarkData == null) { console.log("Not bookmarked"); return; } let result = await LocalAPI.localPostRequest(\`/api/bookmark/delete/\${mediaId}\`); if(!result.success) { ppixiv.message.show(\`Couldn't remove bookmark: \${result.reason}\`); return; } mediaInfo.bookmarkData = null; ppixiv.message.show("Bookmark removed"); } /\x2f Change an existing bookmark to public or private. static async bookmarkSetPrivate(mediaId, private_bookmark) { if(helpers.mediaId.isLocal(mediaId)) return; let mediaInfo = await ppixiv.mediaCache.getMediaInfo(mediaId, { full: false }); if(!mediaInfo.bookmarkData) { console.log(\`Illust \${mediaId} wasn't bookmarked\`); return; } let bookmarkId = mediaInfo.bookmarkData.id; let result = await helpers.pixivRequest.post("/ajax/illusts/bookmarks/edit_restrict", { bookmarkIds: [bookmarkId], bookmarkRestrict: private_bookmark? "private":"public", }); /\x2f Update bookmark info. mediaInfo.bookmarkData = { id: bookmarkId, private: private_bookmark, }; ppixiv.message.show(private_bookmark? "Bookmarked privately":"Bookmarked"); } /\x2f Show a prompt to enter tags, so the user can add tags that aren't already in the /\x2f list. Add the bookmarks to recents, and bookmark the image with the entered tags. static async addNewBookmarkTag(mediaId) { console.log("Show tag prompt"); let prompt = new TextPrompt({ title: "New tag:" }); let tags = await prompt.result; if(tags == null) return; /\x2f cancelled /\x2f Split the new tags. tags = tags.split(" "); tags = tags.filter((value) => { return value != ""; }); /\x2f This should already be loaded, since the only way to open this prompt is /\x2f in the tag dropdown. let bookmarkTags = await ppixiv.extraCache.loadBookmarkDetails(mediaId); /\x2f Add each tag the user entered to the tag list to update it. let activeTags = [...bookmarkTags]; for(let tag of tags) { if(activeTags.indexOf(tag) != -1) continue; /\x2f Add this tag to recents. bookmarkAdd will add recents too, but this makes sure /\x2f that we add all explicitly entered tags to recents, since bookmarkAdd will only /\x2f add tags that are new to the image. RecentBookmarkTags.updateRecentBookmarkTags([tag]); activeTags.push(tag); } console.log("All tags:", activeTags); /\x2f Edit the bookmark. if(helpers.mediaId.isLocal(mediaId)) await Actions._localBookmarkAdd(mediaId, { tags: activeTags }); else await Actions.bookmarkAdd(mediaId, { tags: activeTags, }); } /\x2f If quiet is true, don't print any messages. static async likeImage(mediaId, quiet) { if(helpers.mediaId.isLocal(mediaId)) return; let illustId = helpers.mediaId.toIllustIdAndPage(mediaId)[0]; console.log("Clicked like on", mediaId); if(ppixiv.extraCache.getLikedRecently(mediaId)) { if(!quiet) ppixiv.message.show("Already liked this image"); return; } let result = await helpers.pixivRequest.post("/ajax/illusts/like", { "illust_id": illustId, }); /\x2f If is_liked is true, we already liked the image, so this had no effect. let wasAlreadyLiked = result.body.is_liked; /\x2f Remember that we liked this image recently. ppixiv.extraCache.addLikedRecently(mediaId); /\x2f If we have illust data, increase the like count locally. Don't load it /\x2f if it's not loaded already. let mediaInfo = ppixiv.mediaCache.getMediaInfoSync(mediaId); if(!wasAlreadyLiked && mediaInfo) mediaInfo.likeCount++; if(!quiet) { if(wasAlreadyLiked) ppixiv.message.show("Already liked this image"); else ppixiv.message.show("Illustration liked"); } } /\x2f Follow userId with the given privacy and tag list. /\x2f /\x2f The follow editing API has a bunch of quirks. You can call bookmarkAdd on a user /\x2f you're already following, but it'll only update privacy and not tags. Editing tags /\x2f is done with following_user_tag_add/following_user_tag_delete (and can only be done /\x2f one at a time). /\x2f /\x2f A tag can only be set with this call if the caller knows we're not already following /\x2f the user, eg. if the user clicks a tag in the follow dropdown for an unfollowed user. /\x2f If we're editing an existing follow's tag, use changeFollowTags below. We do handle /\x2f changing privacy here. static async follow(userId, followPrivately, { tag=null }={}) { if(userId == -1) return; /\x2f We need to do this differently depending on whether we were already following the user. let userInfo = await ppixiv.userCache.getUserInfo(userId, { full: true }); if(userInfo.isFollowed) { /\x2f If we were already following, we're just updating privacy. We don't update follow /\x2f tags for existing follows this way. console.assert(tag == null); return await Actions.changeFollowPrivacy(userId, followPrivately); } /\x2f This is a new follow. /\x2f /\x2f If bookmark_privately_by_default is enabled and private wasn't specified /\x2f explicitly, set it to true. if(followPrivately == null && ppixiv.settings.get("bookmark_privately_by_default")) followPrivately = true; let followArgs = { mode: "add", type: "user", user_id: userId, tag: tag ?? "", restrict: followPrivately? 1:0, format: "json", }; /\x2f Pixiv enables recaptcha for follows only for some users. If it's enabled for this user, /\x2f get a token for the follow. let useRecaptcha = ppixiv.pixivInfo?.pixivTests?.recaptcha_follow_user; if(useRecaptcha) { console.log("Requesting recaptcha token for follow"); let token = await Recaptcha.getRecaptchaToken("www/follow_user"); if(token == null) { ppixiv.message.show("Couldn't get Recaptcha token for following a user"); return; } followArgs.recaptcha_enterprise_score_token = token; } /\x2f This doesn't return any data, but returns a 400 Bad Request if something fails. let result = await helpers.pixivRequest.rpcPost("/bookmark_add.php", followArgs); if(result.error) { ppixiv.message.show(\`Error following user \${userId}: \${result.message}\`); return; } /\x2f Cache follow info for this new follow. Since we weren't followed before, we know /\x2f we can just create a new entry. let tagSet = new Set(); if(tag != null) { tagSet.add(tag); ppixiv.userCache.addCachedUserFollowTags(tag); } let info = { tags: tagSet, followingPrivately: followPrivately, }; ppixiv.userCache.updateCachedFollowInfo(userId, true, info); let message = "Followed " + userInfo.name; if(followPrivately) message += " privately"; ppixiv.message.show(message); } /\x2f Change the privacy status of a user we're already following. static async changeFollowPrivacy(userId, followPrivately) { let data = await helpers.pixivRequest.rpcPost("/ajax/following/user/restrict_change", { user_id: userId, restrict: followPrivately? 1:0, }); if(data.error) { ppixiv.message.show(\`Error changing follow privacy: \${data.message}\`); return; } /\x2f If we had cached follow info, update it with the new privacy. let info = ppixiv.userCache.getUserFollowInfoSync(userId); if(info != null) { console.log("Updating cached follow privacy"); info.followingPrivately = followPrivately; ppixiv.userCache.updateCachedFollowInfo(userId, true, info); } let userInfo = await ppixiv.userCache.getUserInfo(userId); let message = \`Now following \${userInfo.name} \${followPrivately? "privately":"publically"}\`; ppixiv.message.show(message); } /\x2f Add or remove a follow tag for a user we're already following. The API only allows /\x2f editing one tag per call. static async changeFollowTags(userId, {tag, add}) { let data = await helpers.pixivRequest.rpcPost(add? "/ajax/following/user/tag_add":"/ajax/following/user/tag_delete", { user_id: userId, tag, }); if(data.error) { ppixiv.message.show(\`Error editing follow tags: \${data.message}\`); return; } let userInfo = await ppixiv.userCache.getUserInfo(userId); let message = add? \`Added the tag "\${tag}" to \${userInfo.name}\`:\`Removed the tag "\${tag}" from \${userInfo.name}\`; ppixiv.message.show(message); /\x2f Get follow info so we can update the tag list. This will usually already be loaded, /\x2f since the caller will have had to load it to show the UI in the first place. let followInfo = await ppixiv.userCache.getUserFollowInfo(userId); if(followInfo == null) { ppixiv.message.show("Error retrieving follow info to update tags"); return; } if(add) { followInfo.tags.add(tag); /\x2f Make sure the tag is in the full tag list too. ppixiv.userCache.addCachedUserFollowTags(tag); } else followInfo.tags.delete(tag); ppixiv.userCache.updateCachedFollowInfo(userId, true, followInfo); } static async unfollow(userId) { if(userId == -1) return; let result = await helpers.pixivRequest.rpcPost("/rpc_group_setting.php", { mode: "del", type: "bookuser", id: userId, }); let userData = await ppixiv.userCache.getUserInfo(userId); /\x2f Record that we're no longer following and refresh the UI. ppixiv.userCache.updateCachedFollowInfo(userId, false); ppixiv.message.show("Unfollowed " + userData.name); } /\x2f Image downloading /\x2f /\x2f Download mediaInfo. static async downloadIllust(mediaId, downloadType) { let mediaInfo = await ppixiv.mediaCache.getMediaInfo(mediaId); let userInfo = await ppixiv.userCache.getUserInfo(mediaInfo.userId); console.log(\`Download \${mediaId} with type \$[downloadType}\`); if(downloadType == "MKV") { new PixivUgoiraDownloader(mediaInfo); return; } if(downloadType != "image" && downloadType != "ZIP") { console.error("Unknown download type " + downloadType); return; } /\x2f If we're in ZIP mode, download all images in the post. let pages = []; for(let page = 0; page < mediaInfo.mangaPages.length; ++page) pages.push(page); /\x2f If we're in image mode for a manga post, only download the requested page. let mangaPage = helpers.mediaId.parse(mediaId).page; if(downloadType == "image") pages = [mangaPage]; ppixiv.message.show(pages.length > 1? \`Downloading \${pages.length} pages...\`:\`Downloading image...\`); let results = []; try { for(let page of pages) { let pageMediaId = helpers.mediaId.getMediaIdForPage(mediaId, page); /\x2f If translations for this image are enabled, try to save the image with translations. /\x2f If translations are disabled for this image, this will be null. let translatedCanvas = await ppixiv.imageTranslations.getTranslatedImage(pageMediaId); if(translatedCanvas != null) { let blob = await helpers.other.canvasToBlob(translatedCanvas, { type: "image/jpeg", quality: 0.95 }); let result = await blob.arrayBuffer(); result.extension = "jpg"; results.push(result); continue; } /\x2f Download the image normally. let url = mediaInfo.mangaPages[page].urls.original; let result = await downloadPixivImage(url); result.extension = helpers.strings.getExtension(url); results.push(result); } } catch(e) { ppixiv.message.show(e.toString()); return; } ppixiv.message.hide(); /\x2f If there's just one image, save it directly. if(pages.length == 1) { let blob = new Blob([results[0]]); let ext = results[0].extension; let filename = userInfo.name + " - " + mediaInfo.illustId; /\x2f If this is a single page of a manga post, include the page number. if(downloadType == "image" && mediaInfo.mangaPages.length > 1) filename += " #" + (mangaPage + 1); filename += " - " + mediaInfo.illustTitle + "." + ext; helpers.saveBlob(blob, filename); return; } /\x2f There are multiple images, and since browsers are stuck in their own little world, there's /\x2f still no way in 2018 to save a batch of files to disk, so ZIP the images. let filenames = []; for(let i = 0; i < pages.length; ++i) { let ext = results[i].extension; let filename = i.toString().padStart(3, '0') + "." + ext; filenames.push(filename); } /\x2f Create the ZIP. let zip = new CreateZIP(filenames, results); let filename = userInfo.name + " - " + mediaInfo.illustId + " - " + mediaInfo.illustTitle + ".zip"; helpers.saveBlob(zip, filename); } static isDownloadTypeAvailable(downloadType, mediaInfo) { if(ppixiv.mobile) return false; /\x2f Single image downloading works for single images and manga pages. if(downloadType == "image") return mediaInfo.illustType != 2; /\x2f ZIP downloading only makes sense for image sequences. if(downloadType == "ZIP") return mediaInfo.illustType != 2 && mediaInfo.pageCount > 1; /\x2f MJPEG only makes sense for videos. if(downloadType == "MKV") return mediaInfo.illustType == 2; throw "Unknown download type " + downloadType; }; static async loadRecentBookmarkTags() { if(ppixiv.native) return await LocalAPI.loadRecentBookmarkTags(); let url = "/ajax/user/" + ppixiv.pixivInfo.userId + "/illusts/bookmark/tags"; let result = await helpers.pixivRequest.get(url, {}); let bookmarkTags = []; let addTag = (tag) => { /\x2f Ignore "untagged". if(tag.tag == "未分類") return; if(bookmarkTags.indexOf(tag.tag) == -1) bookmarkTags.push(tag.tag); } for(let tag of result.body.public) addTag(tag); for(let tag of result.body.private) addTag(tag); return bookmarkTags; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/actions.js `), "/vview/misc/create-zip.js": loadBlob("application/javascript", `/\x2f Create an uncompressed ZIP from a list of files and filenames. import crc32 from '/vview/util/crc32.js'; import struct from '/vview/util/struct.js'; export default class CreateZIP { constructor(filenames, files) { if(filenames.length != files.length) throw "Mismatched array lengths"; /\x2f Encode the filenames. let filenameBlobs = []; for(let i = 0; i < filenames.length; ++i) { let filename = new Blob([filenames[i]]); filenameBlobs.push(filename); } /\x2f Make CRC32s, and create blobs for each file. let blobs = []; let crc32s = []; for(let i = 0; i < filenames.length; ++i) { let data = files[i]; let crc = crc32(new Int8Array(data)); crc32s.push(crc); blobs.push(new Blob([data])); } let parts = []; let filePos = 0; let fileOffsets = []; for(let i = 0; i < filenames.length; ++i) { let filename = filenameBlobs[i]; let data = blobs[i]; let crc = crc32s[i]; /\x2f Remember the position of the local file header for this file. fileOffsets.push(filePos); let localFileHeader = this.createLocalFileHeader(filename, data, crc); parts.push(localFileHeader); filePos += localFileHeader.size; /\x2f Add the data. parts.push(data); filePos += data.size; } /\x2f Create the central directory. let centralDirectoryPos = filePos; let centralDirectorySize = 0; for(let i = 0; i < filenames.length; ++i) { let filename = filenameBlobs[i]; let data = blobs[i]; let crc = crc32s[i]; let fileOffset = fileOffsets[i]; let centralRecord = this.createCentralDirectoryEntry(filename, data, fileOffset, crc); centralDirectorySize += centralRecord.size; parts.push(centralRecord); } let endCentralRecord = this.createEndCentral(filenames.length, centralDirectoryPos, centralDirectorySize); parts.push(endCentralRecord); return new Blob(parts, { "type": "application/zip", }); } createLocalFileHeader(filename, file, crc) { let data = struct(" true, /\x2f This is called if we were cancelled after confirmDrag by another dragger starting first. oncancelled, /\x2f Called if a click is confirmed with confirmDrag but released or cancelled without actually /\x2f starting a drag. This is useful as an alternative to onclick, since click events are still /\x2f sent after drags end. onReleasedWithoutDrag=({interactive, cancel}) => true, /\x2f Called when the drag starts, which is the first pointer movement after confirmDrag. /\x2f If false is returned, the drag is cancelled. If this happens when deferredStart is true, /\x2f the drag won't be started and won't interrupt other drags. /\x2f /\x2f If the drag is starting due to deferDelayMs, event is null because it's not starting /\x2f as the result of a pointer event. ondragstart = ({event}) => true, /\x2f ondrag({event, first}) /\x2f first is true if this is the first pointer movement since this drag started. ondrag, /\x2f Called when the drag is released. ondragend, /\x2f True if the caller is using this dragger for pinch gestures. pinch=false, /\x2f If this returns true (the default), the drag will start on the first pointer movement. /\x2f If false, the drag will start immediately on pointerdown. deferredStart=() => true, /\x2f If we're deferring the start of the drag, this is the minimum delay we need to see before /\x2f pointer movements. We'll ignore the drag if we see movement before this, and start the /\x2f drag as soon as this period elapses. deferDelayMs=null, ...options }={}) { super(options); this.name = name; this.element = element; this.pointers = new Map(); this.confirmDrag = confirmDrag; this.onReleasedWithoutDrag = onReleasedWithoutDrag; this.oncancelled = oncancelled; this.ondragstart = ondragstart; this.ondrag = ondrag; this.ondragend = ondragend; this.pinch = pinch; this.deferredStart = deferredStart; this.deferDelayMs = deferDelayMs; this._dragStarted = false; this._dragDelayTimer = null; signal ??= (new AbortController().signal); this._touchListener = new TouchListener({ parent: this, element, multi: true, callback: this._pointerevent, }); signal.addEventListener("abort", () => this.cancelDrag()); } shutdown() { RunningDrags.remove(this); super.shutdown(); } _pointerevent = (e) => { /\x2f Ignore presses while another dragger is active. if(RunningDrags.activeDrag && RunningDrags.activeDrag != this) return; if(e.pressed) { if(this.pointers.size == 0) { if(!this.confirmDrag({event: e})) return; } this._startDragging(e); } else { if(!this.pointers.has(e.pointerId)) return; this.pointers.delete(e.pointerId); /\x2f If this was the last pointer, end the drag. if(this.pointers.size == 0) this._stopDragging({ interactive: true, cancel: e.type == "pointercancel" }); } } async _startDragging(event) { this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY, /\x2f Pointer movements are thresholded: we don't get pointer movements until the /\x2f touch has moved some minimum amount, and all movement until then will be /\x2f bundled into the first pointermove event. Ignore that first event, since it /\x2f makes drags look jerky. ignoreNextPointermove: true, }); if(this.pinch && this._dragDelayTimer != null && this.pointers.size > 1) { /\x2f We were in deferDelayMs and a second tap started. Cancel the delay and /\x2f start immediately for pinch zooming. /\x2f console.log("Starting deferred drag due to multitouch"); realClearTimeout(this._dragDelayTimer); this._dragDelayTimer = null; this._commitStartDragging({event: null}); } if(this.pointers.size > 1) return; window.addEventListener("pointermove", this._pointermove, this._signal); this._dragStarted = false; RunningDrags.add(this, ({otherDragger}) => { this.cancelDrag(); if(this.oncancelled) this.oncancelled({otherDragger}); }); /\x2f Ask the caller if we want to defer the start of the drag until the first pointer /\x2f movement. If we don't, start it now, otherwise we'll start it in pointermove later. if(!this.deferredStart()) this._commitStartDragging({event}); else if(this.deferDelayMs != null) { /\x2f We're deferring the drag. Start a timer to stop deferring after a timeout. this._dragDelayTimer = realSetTimeout(() => { this._dragDelayTimer = null; this._commitStartDragging({event: null}); }, this.deferDelayMs); } } /\x2f Actually start the drag. This may happen immediately on pointerdown or on the first pointermove. /\x2f event is a PointerEvent, but may be either pointerdown or pointermove. async _commitStartDragging({event}) { if(this._dragStarted) return; if(!this.ondragstart({event})) { this._stopDragging(); return; } this._dragStarted = true; RunningDrags.cancelOtherDrags(this); } /\x2f Return true if a drag is active. get isDragging() { return this._dragStarted; } /\x2f If a drag is active, cancel it. cancelDrag() { this._stopDragging({interactive: false}); } /\x2f Stop any active or potential drag. /\x2f /\x2f interactive is true if this is the user releasing it, or false if we're shutting /\x2f down during a drag. cancel is true if this is due to a pointercancel event. _stopDragging({interactive=false, cancel=false}={}) { this.pointers.clear(); window.removeEventListener("pointermove", this._pointermove); RunningDrags.remove(this); if(this._dragDelayTimer != null) { realClearTimeout(this._dragDelayTimer); this._dragDelayTimer = null; } /\x2f Only send ondragend if we sent ondragstart. if(this._dragStarted) { this._dragStarted = false; if(this.ondragend) this.ondragend({interactive, cancel}); } else this.onReleasedWithoutDrag({interactive, cancel}); } _pointermove = (event) => { let pointerInfo = this.pointers.get(event.pointerId); if(pointerInfo == null) return; /\x2f On iOS, we can do this to allow dragging with a large press without waiting for /\x2f the delay. It's disabled for now since it might make the UI confusing. It probably /\x2f would work better if we had access to haptics. /* if(this.deferDelayMs && this._dragDelayTimer != null && e.width > 50) { realClearTimeout(this._dragDelayTimer); this._dragDelayTimer = null; this._commitStartDragging({event: null}); } */ if(this.deferDelayMs != null && this._dragDelayTimer != null) { /\x2f We saw a pointer movement during the drag delay. Ignore this drag. this.cancelDrag(); return; } /\x2f Call ondragstart the first time we see pointer movement after we begin the drag. This /\x2f is when the drag actually starts. We don't do movement thresholding here since iOS already /\x2f does it (whether we want it to or not). this._commitStartDragging({event}); /\x2f Only handle this as a drag input if we've started treating this as a drag. if(!this._dragStarted) return; /\x2f When we actually handle pointer movement, let IsolatedTapHandler know that this /\x2f press was handled by something. This doesn't actually prevent any default behavior. event.preventDefault(); let info = { event, first: pointerInfo.ignoreNextPointermove, }; pointerInfo.ignoreNextPointermove = false; /\x2f In pinch is enabled, add pinch info. if(this.pinch) { /\x2f The center position and average distance at the start of the frame: let previousCenterPos = this._pointerCenterPos; let previousRadius = this._pointerDistanceFrom(previousCenterPos); /\x2f Update this pointer. This will update _pointerCenterPos. pointerInfo.x = event.clientX; pointerInfo.y = event.clientY; /\x2f The center position and average distance at the end of the frame: let { x, y } = this._pointerCenterPos; let radius = this._pointerDistanceFrom({ x, y }); /\x2f The average pointer movement across the frame: let movementX = x - previousCenterPos.x; let movementY = y - previousCenterPos.y; info = { ...info, /\x2f The average position and movement of all touches: x, y, movementX, movementY, radius, previousRadius, }; } else { info = { ...info, /\x2f When not in pinch (multitouch) mode, we only have one touch. Use its position. movementX: event.movementX, movementY: event.movementY, x: event.clientX, y: event.clientY, radius: 0, previousRadius: 0, } } this.ondrag(info); } /\x2f Get the average position of all current touches. get _pointerCenterPos() { let centerPos = {x: 0, y: 0}; for(let {x, y} of this.pointers.values()) { centerPos.x += x; centerPos.y += y; } centerPos.x /= this.pointers.size; centerPos.y /= this.pointers.size; return centerPos; } /\x2f Return the average distance of all current touches to the given position. _pointerDistanceFrom(pos) { let result = 0; for(let {x, y} of this.pointers.values()) result += helpers.math.distance(pos, {x,y}); result /= this.pointers.size; return result; } }; /\x2f Sometimes we have multiple DragHandlers which can act on the same touch, depending on /\x2f pointer movement after the touch. This tracks the active drags, and allows whichever /\x2f drag activates first to cancel the others. class RunningDrags { static drags = new Map(); /\x2f Add an active dragger. If cancelOtherDrags is called, oncancel() will be called to /\x2f cancel the drag. static add(dragger, oncancel) { /\x2f Sanity check: we should never add new drags to the list while another one is already /\x2f active. It's redundant but OK for the active dragger to re-add itself. if(this._activeDrag != null && this._activeDrag != dragger) { console.log("Adding:", dragger); console.log("Active:", this._activeDrag); throw new Error("Can't add a dragger while one is currently active"); } this.drags.set(dragger, oncancel); } static remove(dragger) { this.drags.delete(dragger); if(dragger == this._activeDrag) this._activeDrag = null; if(this._activeDrag && this.drags.size == 0) console.error("_activeDrag wasn't cleared", dragger); } /\x2f A potential dragger is becoming active, so cancel all other draggers. activeDrag /\x2f is this dragger until it's removed. static cancelOtherDrags(activeDraggers) { if(this._activeDrag != null) { console.log("Dragger was active:", this._activeDrag); throw new Error("Started a drag while another dragger was already active"); } if(!this.drags.has(activeDraggers)) { console.log("activeDraggers:", activeDraggers); throw new Error("Active dragger isn't in the dragger list"); } console.assert(this._activeDrag == null); this._activeDrag = activeDraggers; for(let [dragger, cancelDrag] of this.drags.entries()) { if(dragger === activeDraggers) continue; /\x2f Tell the dragger which other dragger cancelled it. cancelDrag({dragger, otherDragger: activeDraggers}); } } /\x2f If a dragger is active, return it. static get activeDrag() { return this._activeDrag; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/drag-handler.js `), "/vview/misc/encode-mkv.js": loadBlob("application/javascript", `/\x2f This is a simple hack to piece together an MJPEG MKV from a bunch of JPEGs. import struct from '/vview/util/struct.js'; function encodeLength(value) { /\x2f Encode a 40-bit EBML int. This lets us encode 32-bit ints with no extra logic. return struct(">BI").pack(0x08, value); }; function headerInt(container, identifier, value) { container.push(new Uint8Array(identifier)); let data = struct(">II").pack(0, value); let size = data.byteLength; container.push(encodeLength(size)); container.push(data); }; function headerFloat(container, identifier, value) { container.push(new Uint8Array(identifier)); let data = struct(">f").pack(value); let size = data.byteLength; container.push(encodeLength(size)); container.push(data); }; function headerData(container, identifier, data) { container.push(new Uint8Array(identifier)); container.push(encodeLength(data.byteLength)); container.push(data); }; /\x2f Return the total size of an array of ArrayBuffers. function totalSize(array) { let size = 0; for(let idx = 0; idx < array.length; ++idx) { let item = array[idx]; size += item.byteLength; } return size; }; function appendArray(a1, a2) { let result = new Uint8Array(a1.byteLength + a2.byteLength); result.set(new Uint8Array(a1)); result.set(new Uint8Array(a2), a1.byteLength); return result; }; /\x2f Create an EBML block from an identifier and a list of Uint8Array parts. Return a /\x2f single Uint8Array. function createDataBlock(identifier, parts) { identifier = new Uint8Array(identifier); let dataSize = totalSize(parts); let encodedDataSize = encodeLength(dataSize); let result = new Uint8Array(identifier.byteLength + encodedDataSize.byteLength + dataSize); let pos = 0; result.set(new Uint8Array(identifier), pos); pos += identifier.byteLength; result.set(new Uint8Array(encodedDataSize), pos); pos += encodedDataSize.byteLength; for(let i = 0; i < parts.length; ++i) { let part = parts[i]; result.set(new Uint8Array(part), pos); pos += part.byteLength; } return result; }; /\x2f EBML data types function ebmlHeader() { let parts = []; headerInt(parts, [0x42, 0x86], 1); /\x2f EBMLVersion headerInt(parts, [0x42, 0xF7], 1); /\x2f EBMLReadVersion headerInt(parts, [0x42, 0xF2], 4); /\x2f EBMLMaxIDLength headerInt(parts, [0x42, 0xF3], 8); /\x2f EBMLMaxSizeLength headerData(parts, [0x42, 0x82], new Uint8Array([0x6D, 0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61])); /\x2f DocType ("matroska") headerInt(parts, [0x42, 0x87], 4); /\x2f DocTypeVersion headerInt(parts, [0x42, 0x85], 2); /\x2f DocTypeReadVersion return createDataBlock([0x1A, 0x45, 0xDF, 0xA3], parts); /\x2f EBML }; function ebmlInfo(duration) { let parts = []; headerInt(parts, [0x2A, 0xD7, 0xB1], 1000000); /\x2f TimecodeScale headerData(parts, [0x4D, 0x80], new Uint8Array([120])); /\x2f MuxingApp ("x") (this shouldn't be mandatory) headerData(parts, [0x57, 0x41], new Uint8Array([120])); /\x2f WritingApp ("x") (this shouldn't be mandatory) headerFloat(parts, [0x44, 0x89], duration * 1000); /\x2f Duration (why is this a float?) return createDataBlock([0x15, 0x49, 0xA9, 0x66], parts); /\x2f Info }; function ebmlTrackEntryVideo(width, height) { let parts = []; headerInt(parts, [0xB0], width); /\x2f PixelWidth headerInt(parts, [0xBA], height); /\x2f PixelHeight return createDataBlock([0xE0], parts); /\x2f Video }; function ebmlTrackEntry(width, height) { let parts = []; headerInt(parts, [0xD7], 1); /\x2f TrackNumber headerInt(parts, [0x73, 0xC5], 1); /\x2f TrackUID headerInt(parts, [0x83], 1); /\x2f TrackType (video) headerInt(parts, [0x9C], 0); /\x2f FlagLacing headerInt(parts, [0x23, 0xE3, 0x83], 33333333); /\x2f DefaultDuration (overridden per frame) headerData(parts, [0x86], new Uint8Array([0x56, 0x5f, 0x4d, 0x4a, 0x50, 0x45, 0x47])); /\x2f CodecID ("V_MJPEG") parts.push(ebmlTrackEntryVideo(width, height)); return createDataBlock([0xAE], parts); /\x2f TrackEntry }; function ebmlTracks(width, height) { let parts = []; parts.push(ebmlTrackEntry(width, height)); return createDataBlock([0x16, 0x54, 0xAE, 0x6B], parts); /\x2f Tracks }; function ebmlSimpleblock(frameData) { /\x2f We should be able to use encodeLength(1), but for some reason, while everything else /\x2f handles our non-optimal-length ints just fine, this field doesn't. Manually encode it /\x2f instead. let result = new Uint8Array([ 0x81, /\x2f track number 1 (EBML encoded) 0, 0, /\x2f timecode relative to cluster 0x80, /\x2f flags (keyframe) ]); result = appendArray(result, frameData); return result; }; function ebmlCluster(frameData, frameTime) { let parts = []; headerInt(parts, [0xE7], Math.round(frameTime * 1000)); /\x2f Timecode headerData(parts, [0xA3], ebmlSimpleblock(frameData)); /\x2f SimpleBlock return createDataBlock([0x1F, 0x43, 0xB6, 0x75], parts); /\x2f Cluster }; function ebmlCueTrackPositions(filePosition) { let parts = []; headerInt(parts, [0xF7], 1); /\x2f CueTrack headerInt(parts, [0xF1], filePosition); /\x2f CueClusterPosition return createDataBlock([0xB7], parts); /\x2f CueTrackPositions }; function ebmlCuePoint(frameTime, filePosition) { let parts = []; headerInt(parts, [0xB3], Math.round(frameTime * 1000)); /\x2f CueTime parts.push(ebmlCueTrackPositions(filePosition)); return createDataBlock([0xBB], parts); /\x2f CuePoint }; function ebmlCues(frameTimes, frameFilePositions) { let parts = []; for(let frame = 0; frame < frameFilePositions.length; ++frame) { let frameTime = frameTimes[frame]; let filePosition = frameFilePositions[frame]; parts.push(ebmlCuePoint(frameTime, filePosition)); } return createDataBlock([0x1C, 0x53, 0xBB, 0x6B], parts); /\x2f Cues }; function ebmlSegment(parts) { return createDataBlock([0x18, 0x53, 0x80, 0x67], parts); /\x2f Segment }; export default class EncodeMKV { /\x2f We don't decode the JPEG frames while we do this, so the resolution is supplied here. constructor(width, height) { this.width = width; this.height = height; this.frames = []; } add(data, duration) { this.frames.push({ data, duration }); }; build() { /\x2f Sum the duration of the video. let totalDuration = 0; for(let frame = 0; frame < this.frames.length; ++frame) { let { duration } = this.frames; totalDuration += duration / 1000.0; } let headerParts = ebmlHeader(); let parts = []; parts.push(ebmlInfo(totalDuration)); parts.push(ebmlTracks(this.width, this.height)); /\x2f currentPos is the relative position from the start of the segment (after the ID and /\x2f size bytes) to the beginning of the cluster. let currentPos = 0; for(let part of parts) currentPos += part.byteLength; /\x2f Create each frame as its own cluster, and keep track of the file position of each. let frameFilePositions = []; let frameFileTimes = []; let frameTime = 0; for(let frame = 0; frame < this.frames.length; ++frame) { let data = this.frames[frame].data; let ms = this.frames[frame].duration; let cluster = ebmlCluster(data, frameTime); parts.push(cluster); frameFilePositions.push(currentPos); frameFileTimes.push(frameTime); frameTime += ms / 1000.0; currentPos += cluster.byteLength; }; /\x2f Add the frame index. parts.push(ebmlCues(frameFileTimes, frameFilePositions)); /\x2f Create an EBMLSegment containing all of the parts (excluding the header). let segment = ebmlSegment(parts); /\x2f Return a blob containing the final data. let file = []; file = file.concat(headerParts); file = file.concat(segment); return new Blob(file); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/encode-mkv.js `), "/vview/misc/extra-cache.js": loadBlob("application/javascript", `/\x2f This caches media info which isn't a part of regular illust info. import { helpers } from '/vview/misc/helpers.js'; import MediaInfo from '/vview/misc/media-info.js'; export default class ExtraCache { constructor() { this._bookmarkedImageTags = { }; this._recentLikes = { } this._quickUserData = { }; this._getMediaAspectRatioLoads = {}; this._mediaIdAspectRatio = { }; } /\x2f Remember when we've liked an image recently, so we don't spam API requests. getLikedRecently(mediaId) { mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); return this._recentLikes[mediaId]; } addLikedRecently(mediaId) { mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); this._recentLikes[mediaId] = true; } /\x2f Load bookmark tags. /\x2f /\x2f There's no visible API to do this, so we have to scrape the bookmark_add page. I wish /\x2f they'd just include this in bookmarkData. Since this takes an extra request, we should /\x2f only load this if the user is viewing/editing bookmark tags. async loadBookmarkDetails(mediaId) { /\x2f If we know the image isn't bookmarked, we know there are no bookmark tags, so /\x2f we can skip this. mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); let thumb = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }); if(thumb && thumb.bookmarkData == null) return []; /\x2f The local API just puts bookmark info on the illust info. Copy over the current /\x2f data. if(helpers.mediaId.isLocal(mediaId)) this._bookmarkedImageTags[mediaId] = thumb.bookmarkData.tags; /\x2f If we already have bookmark tags, return them. Return a copy, so modifying the /\x2f result doesn't change our cached data. if(this._bookmarkedImageTags[mediaId]) return [...this._bookmarkedImageTags[mediaId]]; let [illustId] = helpers.mediaId.toIllustIdAndPage(mediaId); let bookmarkPage = await helpers.pixivRequest.fetchDocument("/bookmark_add.php?type=illust&illust_id=" + illustId); let tags = bookmarkPage.querySelector(".bookmark-detail-unit form input[name='tag']").value; tags = tags.split(" "); tags = tags.filter((value) => { return value != ""; }); this._bookmarkedImageTags[mediaId] = tags; return this._bookmarkedImageTags[mediaId]; } /\x2f Return bookmark tags if they're already loaded, otherwise return null. getBookmarkDetailsSync(mediaId) { if(helpers.mediaId.isLocal(mediaId)) { let thumb = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }); if(thumb && thumb.bookmarkData == null) return []; this._bookmarkedImageTags[mediaId] = thumb.bookmarkData.tags; return this._bookmarkedImageTags[mediaId]; } else return this._bookmarkedImageTags[mediaId]; } /\x2f Replace our cache of bookmark tags for an image. This is used after updating /\x2f a bookmark. updateCachedBookmarkTags(mediaId, tags) { mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); if(tags == null) delete this._bookmarkedImageTags[mediaId]; else this._bookmarkedImageTags[mediaId] = tags; MediaInfo.callMediaInfoModifiedCallbacks(mediaId); } /\x2f This is a simpler form of thumbnail data for user info. This is just the bare minimum /\x2f info we need to be able to show a user thumbnail on the search page. This is used when /\x2f we're displaying lots of users in search results. /\x2f /\x2f We can get this info from two places, the following page (data_source_follows) and the /\x2f user recommendations page (DataSource_DiscoverUsers). Of course, since Pixiv never /\x2f does anything the same way twice, they have different formats. /\x2f /\x2f The only info we need is: /\x2f userId /\x2f userName /\x2f profileImageUrl addQuickUserData(sourceData, source="normal") { let data = null; if(source == "normal" || source == "following") { data = { userId: sourceData.userId, userName: sourceData.userName, profileImageUrl: sourceData.profileImageUrl, }; } else if(source == "recommendations") { data = { userId: sourceData.userId, userName: sourceData.name, profileImageUrl: sourceData.imageBig, }; } else if(source == "users_bookmarking_illust") { data = { userId: sourceData.user_id, userName: sourceData.user_name, profileImageUrl: sourceData.profile_img, }; } else throw "Unknown source: " + source; this._quickUserData[data.userId] = data; } getQuickUserData(userId) { return this._quickUserData[userId]; } /\x2f Image aspect ratios from thumbnails /\x2f /\x2f Pixiv doesn't include image dimensions for manga pages in most APIs, so it takes an extra /\x2f round trip to get them, and we don't want to do that in bulk and spam the server. For /\x2f anything we can make do with just the aspect ratio, we can load the thumbnail and just /\x2f look at its size. This is a lot more reasonable to load in bulk (that's what they're for), /\x2f and we're usually loading them anyway. /\x2f /\x2f By default this requires that media info already be cached. This is done when we add data /\x2f from data sources, where we should already be caching this info, and this makes sure we don't /\x2f accidentally make hundreds of individual media info lookups if that doesn't happen. /\x2f /\x2f Note that the aspect ratio from this is approximate, since it's quantized by the thumbnail /\x2f resolution. getMediaAspectRatio(mediaId, { allowMediaInfoLoad=false }={}) { if(this._mediaIdAspectRatio[mediaId] != null) return this._mediaIdAspectRatio[mediaId]; if(this._getMediaAspectRatioLoads[mediaId]) return this._getMediaAspectRatioLoads[mediaId]; let promise = this._getMediaAspectRatioInner(mediaId, { allowMediaInfoLoad }); this._getMediaAspectRatioLoads[mediaId] = promise; promise.then((result) => { this._mediaIdAspectRatio[mediaId] = result; }); promise.finally(() => { delete this._getMediaAspectRatioLoads[mediaId]; }); return promise; } getMediaAspectRatioSync(mediaId) { return this._mediaIdAspectRatio[mediaId]; } async _getMediaAspectRatioInner(mediaId, { allowMediaInfoLoad=false }={}) { let mediaInfo = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }); if(mediaInfo == null) { if(!allowMediaInfoLoad) { console.error(\`getMediaResolution(\${mediaId}): media info wasn't loaded\`); return null; } mediaInfo = await ppixiv.mediaCache.getMediaInfo(mediaId, { full: false }); } /\x2f We always have the resolution for local images. if(helpers.mediaId.isLocal(mediaId)) return mediaInfo.width / mediaInfo.height; let page = helpers.mediaId.parse(mediaId).page; let url = mediaInfo.previewUrls[page]; let img = document.createElement("img"); img.src = url; return await this.registerLoadingThumbnail(mediaId, img); } /\x2f Return { aspectRatios, promise }. aspectRatios is a dictionary of IDs to aspect /\x2f ratios we already know. If any aren't known and a lookup is started, promise will /\x2f resolve when the lookups complete, otherwise promise is null. batchGetMediaAspectRatio(mediaIds) { let aspectRatios = {}; let promises = []; for(let mediaId of mediaIds) { let aspectRatio = this.getMediaAspectRatioSync(mediaId); if(aspectRatio != null) aspectRatios[mediaId] = aspectRatio; else promises.push(this.getMediaAspectRatio(mediaId)); } let promise = promises.length > 0? Promise.all(promises):null; return { aspectRatios, promise }; } /\x2f Register a thumbnail image that's being loaded. This can be called if we're loading an /\x2f image thumbnail, so we'll remember its resolution for future calls to getMediaAspectRatio. async registerLoadingThumbnail(mediaId, img) { await helpers.other.waitForImageLoad(img); /\x2f If the image load fails, waitForImageLoad will still resolve. Store a fallback aspect /\x2f ratio, so we can't end up getting stuck trying to load a broken image over and over. /\x2f waitForImageLoad will still resolve if the image load fails let aspectRatio = img.naturalWidth / img.naturalHeight; if(img.naturalHeight == 0) aspectRatio = 0; this._mediaIdAspectRatio[mediaId] = aspectRatio; return aspectRatio; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/extra-cache.js `), "/vview/misc/extra-image-data.js": loadBlob("application/javascript", `import KeyStorage from '/vview/misc/key-storage.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f This database is used to store extra metadata for Pixiv images. It's similar /\x2f to the metadata files in the local database. /\x2f /\x2f Data is stored by media ID, with a separate record for each manga page. We /\x2f have an index on the illust ID, so we can fetch all pages for an illust ID quickly. export default class ExtraImageData { constructor() { /\x2f This is only needed for storing data for Pixiv images. We don't need it if /\x2f we're native. if(ppixiv.native) return; this.db = new KeyStorage("ppixiv-image-data", { upgradeDb: this.upgradeDb }); } upgradeDb = (e) => { /\x2f Create our object store with an index on illust_id. let db = e.target.result; let store = db.createObjectStore("ppixiv-image-data"); store.createIndex("illust_id", "illust_id"); store.createIndex("edited_at", "edited_at"); } async updateMediaId(mediaId, data) { if(this.db == null) return; /\x2f Request permission storage the first time the user saves image edits. Browsers /\x2f seem to handle not spamming requests for this, but for safety we only do this once /\x2f per session. We don't need to wait for this. if(!this._requestedPersistentStorage && navigator.storage?.persist) { this._requestedPersistentStorage = true; navigator.storage.persist(); } await this.db.set(mediaId, data); } async deleteMediaId(mediaId) { if(this.db == null) return; await this.db.delete(mediaId); } /\x2f Return extra data for the given media IDs if we have it, as a mediaId: data dictionary. async loadMediaId(mediaIds) { if(this.db == null) return {}; return await this.db.dbOp(async (db) => { let store = this.db.getStore(db); /\x2f Load data in bulk. let promises = {}; for(let mediaId of mediaIds) { let data = KeyStorage.asyncStoreGet(store, mediaId); if(data) promises[mediaId] = data; } return await helpers.other.awaitMap(promises); }) ?? {}; } /\x2f Return data for all pages of mediaId. async loadAllPagesForIllust(illustId) { if(this.db == null) return {}; return await this.db.dbOp(async (db) => { let store = this.db.getStore(db); let index = store.index("illust_id"); let query = IDBKeyRange.only(illustId); let cursor = index.openCursor(query); let results = {}; for await (let entry of cursor) { let mediaId = entry.primaryKey; results[mediaId] = entry.value; } return results; }) ?? {}; } /\x2f Batch load a list of illustIds. The results are returned mapped by illustId. async batchLoadAllPagesForIllust(illustIds) { if(this.db == null) return {}; return await this.db.dbOp(async (db) => { let store = this.db.getStore(db); let index = store.index("illust_id"); let promises = {}; for(let illustId of illustIds) { let query = IDBKeyRange.only(illustId); let cursor = index.openCursor(query); promises[illustId] = (async() => { let results = {}; for await (let entry of cursor) { let mediaId = entry.primaryKey; results[mediaId] = entry.value; } return results; })(); } return await helpers.other.awaitMap(promises); }) ?? {}; } /\x2f Return the media ID of all illust IDs. /\x2f /\x2f Note that we don't use an async iterator for this, since it might not be closed /\x2f until it's GC'd and we need to close the database consistently. async getAllEditedImages({sort="time"}={}) { console.assert(sort == "time" || sort == "id"); if(this.db == null) return []; return await this.db.dbOp(async (db) => { let store = this.db.getStore(db); let index = sort == "time"? store.index("edited_at"):store; let cursor = index.openKeyCursor(null, sort == "time"? "prev":"next"); /\x2f descending for time let results = []; for await (let entry of cursor) { let mediaId = entry.primaryKey; results.push(mediaId); } return results; }) ?? []; } /\x2f Export the database contents to allow the user to back it up. async export() { if(this.db == null) throw new Error("ExtraImageData is disabled"); let data = await this.db.dbOp(async (db) => { let store = this.db.getStore(db); let cursor = store.openCursor(); let results = []; for await (let entry of cursor) { /\x2f We store pages in the key as a media ID. Add it to the exported value. results.push({ media_id: entry.key, ...entry.value, }); } return results; }) ?? []; let exportedData = { type: "ppixiv-image-data", data, }; if(exportedData.data.length == 0) { ppixiv.message.show("No edited images to export."); return; } let json = JSON.stringify(exportedData, null, 4); let blob = new Blob([json], { type: "application/json" }); helpers.saveBlob(blob, "ppixiv image edits.json"); } /\x2f Import data exported by export(). This will overwrite any overlapping entries, but entries /\x2f won't be deleted if they don't exist in the input. async import() { if(this.db == null) throw new Error("ExtraImageData is disabled"); /\x2f This API is annoying: it throws an exception (rejects the promise) instead of /\x2f returning null. Exceptions should be used for unusual errors, not for things /\x2f like the user cancelling a file dialog. let files; try { files = await window.showOpenFilePicker({ multiple: false, types: [{ description: 'Exported image edits', accept: { 'application/json': ['.json'], } }], }); } catch(e) { return; } let file = await files[0].getFile(); let data = JSON.parse(await file.text()); if(data.type != "ppixiv-image-data") { ppixiv.message.show(\`The file "\${file.name}" doesn't contain exported image edits.\`); return; } let dataByMediaId = {}; for(let entry of data.data) { let mediaId = entry.media_id; delete entry.media_id; dataByMediaId[mediaId] = entry; } console.log(\`Importing data:\`, data); await this.db.multiSet(dataByMediaId); /\x2f Tell MediaCache that we've replaced extra data, so any loaded images are updated. for(let [mediaId, data] of Object.entries(dataByMediaId)) ppixiv.mediaCache.replaceExtraData(mediaId, data); ppixiv.message.show(\`Imported edits for \${data.data.length} \${data.data.length == 1? "image":"images"}.\`); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/extra-image-data.js `), "/vview/misc/fix-chrome-clicks.js": loadBlob("application/javascript", `/\x2f Fix Chrome's click behavior. /\x2f /\x2f Work around odd, obscure click behavior in Chrome: releasing the right mouse /\x2f button while the left mouse button is held prevents clicks from being generated /\x2f when the left mouse button is released (even if the context menu is cancelled). /\x2f This causes lost inputs when quickly right-left clicking our context menu. /\x2f /\x2f Unfortunately, we have to reimplement the click event in order to do this. /\x2f We only attach this handler where it's really needed (the popup menu). /\x2f /\x2f We mimic Chrome's click detection behavior: an element is counted as a click if /\x2f the mouseup event is an ancestor of the element that was clicked, or vice versa. /\x2f This is different from Firefox which uses the distance the mouse has moved. import { helpers } from '/vview/misc/helpers.js'; export default class FixChromeClicks { constructor(container) { /\x2f Don't do anything if we're not in Chrome. this.enabled = navigator.userAgent.indexOf("Chrome") != -1 && !ppixiv.mobile; if(!this.enabled) return; this.root = container; this.pressedNode = null; /\x2f Since the pointer events API is ridiculous and doesn't send separate pointerdown /\x2f events for each mouse button, we have to listen to all clicks in window in order /\x2f to find out if button 0 is pressed. If the user presses button 2 outside of our /\x2f container we still want to know about button 0, but that button 0 event might happen /\x2f in another element that we don't care about. this.root.addEventListener("pointerdown", this.onpointer, true); this.root.addEventListener("pointerup", this.onpointer, true); this.root.addEventListener("pointermove", this.onpointer, true); this.root.addEventListener("contextmenu", this.oncontextmenu); this.root.addEventListener("click", this.onclick, true); } /\x2f We have to listen on window as well as our container for events, since a /\x2f mouse up might happen on another node after the mouse down happened in our /\x2f node. We only register these while a button is pressed in our node, so we /\x2f don't have global pointer event handlers installed all the time. startWaitingForRelease() { if(this.pressedNode != null) { console.warn("Unexpected call to startWaitingForRelease"); return; } window.addEventListener("pointerup", this.onpointer, true); window.addEventListener("pointermove", this.onpointer, true); } stopWaitingForRelease() { if(this.pressedNode == null) return; window.removeEventListener("pointerup", this.onpointer, true); window.removeEventListener("pointermove", this.onpointer, true); this.pressedNode = null; } /\x2f The pointer events API is nonsensical: button presses generate pointermove /\x2f instead of pointerdown or pointerup if another button is already pressed. That's /\x2f completely useless, so we have to just listen to all of them the same way and /\x2f deduce what's happening from the button mask. onpointer = (e) => { if(e.type == "pointerdown") { /\x2f Start listening to move events. We only need this while a button /\x2f is pressed. this.startWaitingForRelease(); } if(e.buttons & 1) { /\x2f The primary button is pressed, so remember what element we were on. if(this.pressedNode == null) { /\x2f console.log("mousedown", e.target.id); this.pressedNode = e.target; } return; } if(this.pressedNode == null) return; let pressedNode = this.pressedNode; /\x2f The button was released. Unregister our temporary event listeners. this.stopWaitingForRelease(); /\x2f console.log("released:", e.target.id, "after click on", pressedNode.id); let releasedNode = e.target; let clickTarget = null; if(helpers.html.isAbove(releasedNode, pressedNode)) clickTarget = releasedNode; else if(helpers.html.isAbove(pressedNode, releasedNode)) clickTarget = pressedNode; if(clickTarget == null) { /\x2f console.log("No target for", pressedNode, "and", releasedNode); return; } /\x2f If the click target is above our container, stop. if(helpers.html.isAbove(clickTarget, this.root)) return; /\x2f Why is cancelling the event not preventing mouse events and click events? e.preventDefault(); /\x2f console.log("do click on", clickTarget.id, e.defaultPrevented, e.type); this.sendClickEvent(clickTarget, e); } oncontextmenu = (e) => { if(this.pressedNode != null && !e.defaultPrevented) { console.log("Not sending click because the context menu was opened"); this.pressedNode = null; } } /\x2f Cancel regular mouse clicks. /\x2f /\x2f Pointer events is a broken API. It sends mouse button presses as pointermove /\x2f if another button is already pressed, which already doesn't make sense and /\x2f makes it a headache to use. But, to make things worse, pointermove is defined /\x2f as having the same default event behavior as mousemove, despite the fact that it /\x2f can correspond to a mouse press or release. Also, preventDefault just seems to /\x2f be broken in Chrome and has no effect. /\x2f /\x2f So, we just cancel all button 0 click events that weren't sent by us. onclick = (e) => { if(e.button != 0) return; /\x2f Ignore synthetic events. if(!e.isTrusted) return; e.preventDefault(); e.stopImmediatePropagation(); } sendClickEvent(target, sourceEvent) { let e = new MouseEvent("click", sourceEvent); e.synthetic = true; target.dispatchEvent(e); } shutdown() { if(!this.enabled) return; this.stopWaitingForRelease(); this.pressedNode = null; this.root.removeEventListener("pointerup", this.onpointer, true); this.root.removeEventListener("pointerdown", this.onpointer, true); this.root.removeEventListener("pointermove", this.onpointer, true); this.root.removeEventListener("contextmenu", this.oncontextmenu); this.root.removeEventListener("click", this.onclick, true); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/fix-chrome-clicks.js `), "/vview/misc/guess-artist-links.js": loadBlob("application/javascript", `import KeyStorage from '/vview/misc/key-storage.js'; import { helpers } from '/vview/misc/helpers.js'; export default class GuessArtistLinks { constructor() { this.db = new KeyStorage("ppixiv-artist-info", { upgradeDb: this.upgradeDb }); } upgradeDb = (e) => { let db = e.target.result; let store = db.createObjectStore("ppixiv-artist-info", { keyPath: "user_id illust_id_and_page", }); store.createIndex("user_id", "user_id"); } /\x2f Save data to extra_image_data, and update cached data. Returns the updated extra data. async saveExtraImageData(mediaId, edits) { let [userId] = helpers.mediaId.toIllustIdAndPage(mediaId); /\x2f Load the current data from the database, in case our cache is out of date. let results = await ppixiv.extraImageData.loadMediaId([mediaId]); let data = results[mediaId] ?? { user_id: userId }; /\x2f Update each key, removing any keys which are null. for(let [key, value] of Object.entries(edits)) data[key] = value; /\x2f Update the edited timestamp. data.edited_at = Date.now() / 1000; /\x2f Save the new data. If the only fields left are illustId and edited_at, delete the record. if(Object.keys(data).length == 2) await ppixiv.extraImageData.deleteMediaId(mediaId); else await ppixiv.extraImageData.updateMediaId(mediaId, data); return data; } /\x2f Store info about an image that we've loaded data for. addInfo(userInfo) { /\x2f Everyone else now uses imageData.illustId and imageData.media_id. We /\x2f still just use .id here, since this is only used for Pixiv images and it's /\x2f not worth a migration to change the primary key. /* imageData = { id: imageData.illustId, ...imageData, } */ /\x2f Store one record per page. let pages = []; for(let page = 0; page < imageData.pageCount; ++page) { let illustId = imageData.illustId; let mediaId = helpers.mediaId.fromIllustId(imageData.illustId, page); let userId = userInfo.userId; let url = imageData.mangaPages[page].urls.original; let parts = url.split("."); let ext = parts[parts.length-1]; pages.push({ user_id: userId, illust_id_and_page: mediaId, illust_id: illustId, page: page, }); } /\x2f We don't need to wait for this to finish, but return the promise in case /\x2f the caller wants to. return this.db.multiSetValues(pages); } /\x2f Return the number of images by the given user that have the given file type, /\x2f eg. "jpg". /\x2f /\x2f We have a dedicated index for this, so retrieving the count is fast. async _getFiletypeCountForUser(store, userId, filetype) { let index = store.index("user_id_and_filetype"); let query = IDBKeyRange.only([userId, 0 /* page */, filetype]); return await KeyStorage.awaitRequest(index.count(query)); } /\x2f Try to guess the user's preferred file type. Returns "jpg", "png" or null. guessFileTypeForUserId(userId) { return this.db.dbOp(async (db) => { let store = this.db.getStore(db); /\x2f Get the number of posts by this user with both file types. let jpg = await this._getFiletypeCountForUser(store, userId, "jpg"); let png = await this._getFiletypeCountForUser(store, userId, "png"); /\x2f Wait until we've seen a few images from this user before we start guessing. if(jpg+png < 3) return null; /\x2f If a user's posts are at least 90% one file type, use that type. let jpegFraction = jpg / (jpg+png); if(jpegFraction > 0.9) { console.debug(\`User \${userId} posts mostly JPEGs\`); return "jpg"; } else if(jpegFraction < 0.1) { console.debug(\`User \${userId} posts mostly PNGs\`); return "png"; } else { console.debug(\`Not guessing file types for \${userId} due to too much variance\`); return null; } }); } async _getStoredRecord(mediaId) { return this.db.dbOp(async (db) => { let store = this.db.getStore(db); let record = await KeyStorage.asyncStoreGet(store, mediaId); if(record == null) return null; else return record.url; }); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/guess-artist-links.js `), "/vview/misc/guess-image-url.js": loadBlob("application/javascript", `/\x2f Try to guess the full URL for an image from its preview image and user ID. /\x2f /\x2f The most annoying thing about Pixiv's API is that thumbnail info doesn't include /\x2f image URLs. This means you have to wait for image data to load before you can /\x2f start loading the image at all, and the API call to get image data often takes /\x2f as long as the image load itself. This makes loading images take much longer /\x2f than it needs to. /\x2f /\x2f We can mostly guess the image URL from the thumbnail URL, but we don't know the /\x2f extension. Try to guess. Keep track of which formats we've seen from each user /\x2f as we see them. If we've seen a few posts from a user and they have a consistent /\x2f file type, guess that the user always uses that format. /\x2f /\x2f This tries to let us start loading images earlier, without causing a ton of 404s /\x2f from wrong guesses. import KeyStorage from '/vview/misc/key-storage.js'; import { helpers } from '/vview/misc/helpers.js'; export default class GuessImageURL { constructor() { this.db = new KeyStorage("ppixiv-file-types", { upgradeDb: this.upgradeDb }); } upgradeDb = (e) => { let db = e.target.result; let store = db.createObjectStore("ppixiv-file-types", { keyPath: "illust_id_and_page", }); /\x2f This index lets us look up the number of entries for a given user and filetype /\x2f quickly. /\x2f /\x2f page is included in this so we can limit the search to just page 1. This is so /\x2f a single 100-page post doesn't overwhelm every other post a user makes: we only /\x2f use page 1 when guessing a user's preferred file type. store.createIndex("user_id_and_filetype", ["user_id", "page", "ext"]); } /\x2f Store info about an image that we've loaded data for. addInfo(imageData) { /\x2f Everyone else now uses imageData.illustId and imageData.media_id. We /\x2f still just use .id here, since this is only used for Pixiv images and it's /\x2f not worth a migration to change the primary key. /* imageData = { id: imageData.illustId, ...imageData, } */ /\x2f Store one record per page. let pages = []; for(let page = 0; page < imageData.pageCount; ++page) { let illustId = imageData.illustId; let mediaId = helpers.mediaId.fromIllustId(imageData.illustId, page); let url = imageData.mangaPages[page].urls.original; let parts = url.split("."); let ext = parts[parts.length-1]; pages.push({ illust_id_and_page: mediaId, illust_id: illustId, page: page, user_id: imageData.userId, url: url, ext: ext, }); } /\x2f We don't need to wait for this to finish, but return the promise in case /\x2f the caller wants to. return this.db.multiSetValues(pages); } /\x2f Return the number of images by the given user that have the given file type, /\x2f eg. "jpg". /\x2f /\x2f We have a dedicated index for this, so retrieving the count is fast. async _getFiletypeCountForUser(store, userId, filetype) { let index = store.index("user_id_and_filetype"); let query = IDBKeyRange.only([userId, 0 /* page */, filetype]); return await KeyStorage.awaitRequest(index.count(query)); } /\x2f Try to guess the user's preferred file type. Returns "jpg", "png" or null. guessFileTypeForUserId(userId) { return this.db.dbOp(async (db) => { let store = this.db.getStore(db); /\x2f Get the number of posts by this user with both file types. let jpg = await this._getFiletypeCountForUser(store, userId, "jpg"); let png = await this._getFiletypeCountForUser(store, userId, "png"); /\x2f Wait until we've seen a few images from this user before we start guessing. if(jpg+png < 3) return null; /\x2f If a user's posts are at least 90% one file type, use that type. let jpegFraction = jpg / (jpg+png); if(jpegFraction > 0.9) { console.debug(\`User \${userId} posts mostly JPEGs\`); return "jpg"; } else if(jpegFraction < 0.1) { console.debug(\`User \${userId} posts mostly PNGs\`); return "png"; } else { console.debug(\`Not guessing file types for \${userId} due to too much variance\`); return null; } }); } async _getStoredRecord(mediaId) { return this.db.dbOp(async (db) => { let store = this.db.getStore(db); let record = await KeyStorage.asyncStoreGet(store, mediaId); if(record == null) return null; else return record.url; }); } async guessUrl(mediaId) { /\x2f Guessed preloading is disabled if we're using an image size limit, since /\x2f it's too early to tell which image we'll end up using. if(ppixiv.settings.get("image_size_limit") != null) return null; /\x2f If this is a local URL, we always have the image URL and we don't need to guess. let { type, page } = helpers.mediaId.parse(mediaId); console.assert(type != "folder"); if(type == "file") { let thumb = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }); if(thumb?.illustType == "video") return null; else return thumb?.mangaPages[page]?.urls?.original; } /\x2f If we already have illust info, use it. let mediaInfo = ppixiv.mediaCache.getMediaInfoSync(mediaId); if(mediaInfo != null) return mediaInfo.mangaPages[page].urls.original; /\x2f If we've stored this URL, use it. let storedUrl = await this._getStoredRecord(mediaId); if(storedUrl != null) return storedUrl; /\x2f Get thumbnail data. We need the thumbnail URL to figure out the image URL. let thumb = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }); if(thumb == null) return null; /\x2f Don't bother guessing file types for animations. if(thumb.illustType == 2) return null; /\x2f Try to make a guess at the file type. let guessedFileType = await this.guessFileTypeForUserId(thumb.userId); if(guessedFileType == null) return null; /\x2f Convert the thumbnail URL to the equivalent original URL: /\x2f https:/\x2fi.pximg.net/c/540x540_70 /img-master/img/2021/01/01/01/00/02/12345678_p0_master1200.jpg /\x2f to /\x2f https:/\x2fi.pximg.net /img-original/img/2021/01/01/01/00/02/12345678_p0.jpg let url = thumb.previewUrls[page]; url = url.replace("/c/540x540_70/", "/"); url = url.replace("/img-master/", "/img-original/"); url = url.replace("_master1200.", "."); url = url.replace(/jpg\$/, guessedFileType); return url; } /\x2f This is called if a guessed preload fails to load. This either means we /\x2f guessed wrong, or if we came from a cached URL in the database, that the /\x2f user reuploaded the image with a different file type. async guessedUrlIncorrect(mediaId) { /\x2f If this was a stored URL, remove it from the database. await this.db.multiDelete([mediaId]); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/guess-image-url.js `), "/vview/misc/helpers.js": loadBlob("application/javascript", `import * as math from '/vview/util/math.js'; import * as strings from '/vview/util/strings.js'; import * as html from '/vview/util/html.js'; import * as other from '/vview/util/other.js'; import Args from '/vview/util/args.js'; import * as mediaId from '/vview/util/media-id.js'; import * as pixiv from '/vview/util/pixiv.js'; import * as pixivRequest from '/vview/util/pixiv-request.js'; export class helpers { static getIconClassAndName(iconName) { let [iconSet, name] = iconName.split(":"); if(name == null) { name = iconSet; iconSet = "mat"; } let iconClass = "material-icons"; if(iconSet == "ppixiv") iconClass = "ppixiv-icon"; else if(iconSet == "mat") iconClass = "material-icons"; return [iconClass, name]; } /\x2f Create a font icon. iconName is an icon set and name, eg. "mat:lightbulb" /\x2f for material icons or "ppixiv:icon" for our icon set. If no icon set is /\x2f specified, material icons is used. static createIcon(iconName, { asElement=false, classes=[], align=null, dataset={}, }={}) { let [iconClass, name] = helpers.getIconClassAndName(iconName); let icon = document.createElement("span"); icon.classList.add("font-icon"); icon.classList.add(iconClass); icon.lang = "icon"; icon.innerText = name; for(let className of classes) icon.classList.add(className); if(align != null) icon.style.verticalAlign = align; for(let [key, value] of Object.entries(dataset)) icon.dataset[key] = value; if(asElement) return icon; else return icon.outerHTML; } /\x2f Find elements inside root, and replace them with elements /\x2f from resources: /\x2f /\x2f /\x2f /\x2f Also replace with resource text. This is used for images. static _resource_cache = {}; static replaceInlines(root) { for(let element of root.querySelectorAll("img")) { let src = element.getAttribute("src"); if(!src || !src.startsWith("ppixiv:")) continue; let name = src.substr(7); let resource = ppixiv.resources[name]; if(resource == null) { console.error("Unknown resource \\"" + name + "\\" in", element); continue; } element.setAttribute("src", resource); /\x2f Put the original URL on the element for diagnostics. element.dataset.originalUrl = src; } for(let element of root.querySelectorAll("ppixiv-inline")) { let src = element.getAttribute("src"); /\x2f Import the cached node to make a copy, then replace the element /\x2f with it. let node = helpers.createInlineIcon(src); element.replaceWith(node); /\x2f Copy attributes from the node to the newly created node which /\x2f is replacing it. This can be used for simple things, like setting the id. for(let attr of element.attributes) { if(attr.name == "src") continue; if(node.hasAttribute(attr.name)) { console.error("Node", node, "already has attribute", attr); continue; } node.setAttribute(attr.name, attr.value); } } } /\x2f Create a general-purpose box link. static createBoxLink({ label, link=null, classes="", icon=null, popup=null, /\x2f If set, this is an extra explanation line underneath the label. explanation=null, /\x2f By default, return HTML as text, which is used to add these into templates, which /\x2f is the more common usage. If asElement is true, an element will be returned instead. asElement=false, /\x2f Helpers for ScreenSearch: dataset={}, dataType=null, }) { if(!this._cachedBoxLinkTemplate) { /\x2f We always create an anchor, even if we don't have a link. Browsers just treat it as /\x2f a span when there's no href attribute. /\x2f /\x2f label-box encloses the icon and label, so they're aligned to each other with text spacing, /\x2f which is needed to get text to align with font icons. The resulting box is then spaced as /\x2f a unit within box-link's flexbox. let html = \`
\`; this._cachedBoxLinkTemplate = document.createElement("template"); this._cachedBoxLinkTemplate.innerHTML = html; } let node = helpers.html.createFromTemplate(this._cachedBoxLinkTemplate); if(label != null) { node.querySelector(".label").hidden = false; node.querySelector(".label").innerText = label; } if(link) node.href = link; for(let className of classes || []) node.classList.add(className); if(popup) { node.classList.add("popup"); node.dataset.popup = popup; } if(icon != null) { let [iconClass, iconName] = helpers.getIconClassAndName(icon); let iconElement = node.querySelector(".icon"); iconElement.classList.add(iconClass); iconElement.classList.add("font-icon"); iconElement.hidden = false; iconElement.innerText = iconName; iconElement.lang = "icon"; /\x2f .with.text is set for icons that have text next to them, to enable padding /\x2f and spacing. if(label != null) iconElement.classList.add("with-text"); } if(explanation != null) { let explanation_node = node.querySelector(".explanation"); explanation_node.hidden = false; explanation_node.innerText = explanation; } if(dataType != null) node.dataset.type = dataType; for(let [key, value] of Object.entries(dataset)) node.dataset[key] = value; if(asElement) return node; else return node.outerHTML; } static createInlineIcon(src) { /\x2f Parse this element if we haven't done so yet. if(!this._resource_cache[src]) { /\x2f Find the resource. let resource = ppixiv.resources[src]; if(resource == null) { console.error(\`Unknown resource \${src}\`); return null; } /\x2f resource is HTML. Parse it by adding it to a
. let div = document.createElement("div"); div.innerHTML = resource; let node = div.firstElementChild; node.remove(); /\x2f Stash the source path on the node. This is just for debugging to make /\x2f it easy to tell where things came from. node.dataset.ppixivResource = src; /\x2f Cache the result, so we don't re-parse the node every time we create one. this._resource_cache[src] = node; } let node = this._resource_cache[src]; return document.importNode(node, true); } /\x2f Prompt to save a blob to disk. For some reason, the really basic FileSaver API disappeared from /\x2f the web. static saveBlob(blob, filename) { let blobUrl = URL.createObjectURL(blob); let a = document.createElement("a"); a.hidden = true; a.href = blobUrl; a.download = filename; document.body.appendChild(a); a.click(); /\x2f Clean up. /\x2f /\x2f If we revoke the URL now, or with a small timeout, Firefox sometimes just doesn't show /\x2f the save dialog, and there's no way to know when we can, so just use a large timeout. realSetTimeout(() => { window.URL.revokeObjectURL(blobUrl); a.remove(); }, 1000); } /\x2f Input elements have no way to tell when edits begin or end. The input event tells /\x2f us when the user changes something, but it doesn't tell us when drags begin and end. /\x2f This is important for things like undo: you want to save undo the first time a slider /\x2f value changes during a drag, but not every time, or if the user clicks the slider but /\x2f doesn't actually move it. /\x2f /\x2f This adds events: /\x2f /\x2f editbegin /\x2f edit /\x2f editend /\x2f /\x2f edit events are always surrounded by editbegin and editend. If the user makes multiple /\x2f edits in one action (eg. moving an input slider), they'll be sent in the same begin/end /\x2f block. /\x2f /\x2f This is only currently used for sliders, and doesn't handle things like keyboard navigation /\x2f since that gets overridden by other UI anyway. /\x2f /\x2f signal can be an AbortSignal to remove these event listeners. static watchEdits(input, { signal }={}) { let dragging = false; let insideEdit = false; input.addEventListener("mousedown", (e) => { if(e.button != 0 || dragging) return; dragging = true; }, { signal }); input.addEventListener("mouseup", (e) => { if(e.button != 0 || !dragging) return; dragging = false; if(insideEdit) { insideEdit = false; input.dispatchEvent(new Event("editend")); } }, { signal }); input.addEventListener("input", (e) => { /\x2f Send an editbegin event if we haven't yet. let send_editend = false; if(!insideEdit) { insideEdit = true; input.dispatchEvent(new Event("editbegin")); /\x2f If we're not dragging, this is an isolated edit, so send editend immediately. send_editend = !dragging; } /\x2f The edit event is like input, but surrounded by editbegin/editend. input.dispatchEvent(new Event("edit")); if(send_editend) { insideEdit = false; input.dispatchEvent(new Event("editend")); } }, { signal }); } /\x2f Force all external links to target=_blank on mobile. /\x2f /\x2f This improves links on iOS, especially when running as a PWA: the link will open in a nested Safari /\x2f context and then return to us without reloading when the link is closed. /\x2f /\x2f We currently only look at links when they're first added to the document and don't listen for /\x2f changes to href. static forceTargetBlankOnElement(node) { if(node.href == "" || node.getAttribute("target") == "_blank") return; let url; try { url = new URL(node.href); if(url.origin == document.location.origin) return; } catch(e) { /\x2f Ignore invalid URLs. return; } node.setAttribute("target", "_blank"); } static forceTargetBlank() { if(!ppixiv.mobile) return; function updateNode(node) { if(node.querySelectorAll == null) return; helpers.forceTargetBlankOnElement(node); for(let a of node.querySelectorAll("A:not([target='_blank'])")) helpers.forceTargetBlankOnElement(a); } updateNode(document.documentElement); let observer = new MutationObserver((mutations) => { for(let mutation of mutations) { for(let node of mutation.addedNodes) updateNode(node); } }); observer.observe(document.documentElement, { subtree: true, childList: true }); } /\x2f Work around iOS Safari weirdness. If a drag from the left or right edge of the /\x2f screen causes browser navigation, the underlying window position jumps, which /\x2f causes us to see pointer movement that didn't actually happen. If this happens /\x2f during a drag, it causes the drag to move horizontally by roughly the screen /\x2f width. static shouldIgnoreHorizontalDrag(event) { /\x2f If there are no other history entries, we don't need to do this, since browser back /\x2f can't trigger. if(!ppixiv.ios || window.history.length <= 1) return false; /\x2f Ignore this event if it's close to the left or right edge of the screen. let width = 25; return event.clientX < width || event.clientX > window.innerWidth - width; } static async hideBodyDuringRequest(func) { /\x2f This hack tries to prevent the browser from flickering content in the wrong /\x2f place while switching to and from fullscreen by hiding content while it's changing. /\x2f There's no reliable way to tell when changing opacity has actually been displayed /\x2f since displaying frames isn't synchronized with toggling fullscreen, so we just /\x2f wait briefly based on testing. document.body.style.opacity = 0; let waitPromise = null; try { /\x2f Wait briefly for the opacity change to be drawn. let delay = 50; let start = Date.now(); while(Date.now() - start < delay) await helpers.other.vsync(); /\x2f Start entering or exiting fullscreen. waitPromise = func(); start = Date.now(); while(Date.now() - start < delay) await helpers.other.vsync(); } finally { document.body.style.opacity = 1; } /\x2f Wait for requestFullscreen to finish after restoring opacity, so if it's waiting /\x2f to request permission we won't leave the window blank the whole time. We'll just /\x2f flash black briefly. await waitPromise; } static isFullscreen() { /\x2f In VVbrowser, use our native interface. let vvbrowser = this._vvbrowser(); if(vvbrowser) return vvbrowser.getFullscreen(); if(document.fullscreenElement != null) return true; /\x2f Work around a dumb browser bug: document.fullscreen is false if fullscreen is set by something other /\x2f than the page, like pressing F11, making it a pain to adjust the UI for fullscreen. Try to detect /\x2f this by checking if the window size matches the screen size. This requires working around even more /\x2f ugliness: /\x2f /\x2f - We have to check innerWidth rather than outerWidth. In fullscreen they should be the same since /\x2f there's no window frame, but in Chrome, the inner size is 16px larger than the outer size. /\x2f - innerWidth is scaled by devicePixelRatio, so we have to factor that out. Since this leads to /\x2f fractional values, we also need to threshold the result. /\x2f /\x2f If only there was an API that could just tell us whether we're fullscreened. Maybe it could be called /\x2f "document.fullscreen". We can only dream... let windowWidth = window.innerWidth * devicePixelRatio; let windowHeight = window.innerHeight * devicePixelRatio; if(Math.abs(windowWidth - window.screen.width) < 2 && Math.abs(windowHeight - window.screen.height) < 2) return true; /\x2f In Firefox, outer size is correct, so check it too. This makes us detect fullscreen if inner dimensions /\x2f are reduced by panels in fullscreen. if(window.outerWidth == window.screen.width && window.outerHeight == window.screen.height) return true; return false; } /\x2f If we're in VVbrowser, return the host object implemented in VVbrowserInterface.cpp. Otherwise, /\x2f return null. static _vvbrowser({sync=true}={}) { if(sync) return window.chrome?.webview?.hostObjects?.sync?.vvbrowser; else return window.chrome?.webview?.hostObjects?.vvbrowser; } static async toggleFullscreen() { await helpers.hideBodyDuringRequest(async() => { /\x2f If we're in VVbrowser: let vvbrowser = this._vvbrowser(); if(vvbrowser) { vvbrowser.setFullscreen(!helpers.isFullscreen()); return; } /\x2f Otherwise, use the regular fullscreen API. if(helpers.isFullscreen()) document.exitFullscreen(); else document.documentElement.requestFullscreen(); }); } /\x2f Return true if url1 and url2 are the same, ignoring any language prefix on the URLs. static areUrlsEquivalent(url1, url2) { if(url1 == null || url2 == null) return false; url1 = helpers.pixiv.getUrlWithoutLanguage(url1); url2 = helpers.pixiv.getUrlWithoutLanguage(url2); return url1.toString() == url2.toString(); } static setPageTitle(title) { let title_element = document.querySelector("title"); if(title_element.textContent == title) return; /\x2f Work around a Chrome bug: changing the title by modifying textContent occasionally flickers /\x2f a default title. It seems like it's first assigning "", triggering the default, and then /\x2f assigning the new value. This becomes visible especially on high refresh-rate monitors. /\x2f Work around this by adding a new title element with the new text and then removing the old /\x2f one, which prevents this from happening. This is easy to see by monitoring title change /\x2f messages in VVbrowser. let new_title = document.createElement("title"); new_title.textContent = title; document.head.appendChild(new_title); title_element.remove(); document.dispatchEvent(new Event("windowtitlechanged")); } static setPageIcon(url) { document.querySelector("link[rel='icon']").href = url; } /\x2f Given a list of tags, return the URL to use to search for them. This differs /\x2f depending on the current page. static getArgsForTagSearch(tags, url) { url = helpers.pixiv.getUrlWithoutLanguage(url); let type = helpers.pixiv.getPageTypeFromUrl(url); if(type == "tags") { /\x2f If we're on search already, just change the search tag, so we preserve other settings. /\x2f /tags/tag/artworks -> /tag/new tag/artworks let parts = url.pathname.split("/"); parts[2] = encodeURIComponent(tags); url.pathname = parts.join("/"); } else { /\x2f If we're not, change to search and remove the rest of the URL. url = new URL("/tags/" + encodeURIComponent(tags) + "/artworks#ppixiv", url); } /\x2f Don't include things like the current page in the URL. let args = helpers.getCanonicalUrl(url); return args; } /\x2f Return a canonical URL for a data source. If the canonical URL is the same, /\x2f the same instance of the data source should be used. /\x2f /\x2f A single data source is used eg. for a particular search and search flags. If /\x2f flags are changed, such as changing filters, a new data source instance is created. /\x2f However, some parts of the URL don't cause a new data source to be used. Return /\x2f a URL with all unrelated parts removed, and with query and hash parameters sorted /\x2f alphabetically. static getCanonicalUrl(url, { /\x2f If false, we'll leave the search page and current image in the URL so the data /\x2f source will start where it left off. startAtBeginning=true }={}) { /\x2f Make a copy of the URL. url = new URL(url); /\x2f Remove /en from the URL if it's present. url = helpers.pixiv.getUrlWithoutLanguage(url); let args = new helpers.args(url); /\x2f Remove parts of the URL that don't affect which data source instance is used. /\x2f /\x2f If p=1 is in the query, it's the page number, which doesn't affect the data source. if(startAtBeginning) args.query.delete("p"); /\x2f The manga page doesn't affect the data source. args.hash.delete("page"); /\x2f #view=thumbs controls which view is active. args.hash.delete("view"); /\x2f illust_id in the hash is always just telling us which image within the current /\x2f data source to view. data_sources.current_illust is different and is handled in /\x2f the subclass. if(startAtBeginning) args.hash.delete("illust_id"); /\x2f These are for temp view and don't affect the data source. args.hash.delete("virtual"); args.hash.delete("temp-view"); /\x2f This is for overriding muting. args.hash.delete("view-muted"); /\x2f Ignore filenames for local IDs. if(startAtBeginning) args.hash.delete("file"); /\x2f slideshow is used by the viewer and doesn't affect the data source. args.hash.delete("slideshow"); /\x2f Sort query and hash parameters. args.query = helpers.other.sortQueryParameters(args.query); args.hash = helpers.other.sortQueryParameters(args.hash); return args; } /\x2f Add a basic event handler for an input: /\x2f /\x2f - When enter is pressed, submit will be called. /\x2f - Event propagation will be stopped, so global hotkeys don't trigger. /\x2f /\x2f Note that other event handlers on the input will still be called. static inputHandler(input, submit) { input.addEventListener("keydown", function(e) { /\x2f Always stopPropagation, so inputs aren't handled by main input handling. e.stopPropagation(); /\x2f Note that we need to use e.key here and not e.code. For enter presses /\x2f that are IME confirmations, e.code is still "Enter", but e.key is "Process", /\x2f which prevents it triggering this. if(e.key == "Enter") submit(e); }); } /\x2f Navigate to args, which can be a URL object or a helpers.args. static navigate(args, { /\x2f If true, push the navigation onto browser history. If false, replace the current /\x2f state. addToHistory=true, /\x2f popstate.navigationCause is set to this. This allows event listeners to determine /\x2f what caused a navigation. For browser forwards/back, this won't be present. cause="navigation", /\x2f When navigating from an image to a search, by default we try to scroll to the image /\x2f we came from. If scrollToTop is true, scroll to the top of the search instead. scrollToTop=false, /\x2f We normally synthesize window.onpopstate, so listeners for navigation will see this /\x2f as a normal navigation. If this is false, don't do this. sendPopstate=true, }={}) { if(args instanceof URL) args = new helpers.args(args); /\x2f Store the previous URL for comparison. Normalize it with args, so comparing it with /\x2f toString() is reliable if the escaping is different, such as different %1E case or /\x2f not escaping spaces as +. let old_url = new helpers.args(ppixiv.plocation).toString(); /\x2f Use the history state from args if it exists. let history_data = { ...args.state, }; /\x2f If the state wouldn't change at all, don't set it, so we don't add junk to /\x2f history if the same link is clicked repeatedly. Comparing state via JSON /\x2f is OK here since JS will maintain key order. if(args.url.toString() == old_url && JSON.stringify(history_data) == JSON.stringify(history.state)) return; /\x2f console.log("Changing state to", args.url.toString()); if(addToHistory) ppixiv.phistory.pushState(history_data, "", args.url.toString()); else ppixiv.phistory.replaceState(history_data, "", args.url.toString()); /\x2f Chrome is broken. After replacing state for a while, it starts logging /\x2f /\x2f "Throttling history state changes to prevent the browser from hanging." /\x2f /\x2f This is completely broken: it triggers with state changes no faster than the /\x2f user can move the mousewheel (much too sensitive), and it happens on replaceState /\x2f and not just pushState (which you should be able to call as fast as you want). /\x2f /\x2f People don't think things through. /\x2f console.log("Set URL to", ppixiv.plocation.toString(), addToHistory); if(ppixiv.plocation.toString() != old_url) { if(sendPopstate) { /\x2f Browsers don't send onpopstate for history changes, but we want them, so /\x2f send a synthetic one. /\x2f console.log("Dispatching popstate:", ppixiv.plocation.toString()); let event = new PopStateEvent("pp:popstate"); /\x2f Set initialNavigation to true. This indicates that this event is for a new /\x2f navigation, and not from browser forwards/back. event.navigationCause = cause; event.scrollToTop = scrollToTop; window.dispatchEvent(event); } /\x2f Always dispatch pp:statechange. This differs from popstate (pp:popstate) in that it's /\x2f always sent for all state changes. This is used when we have UI that wants to refresh /\x2f based on the current location, even if it's an in-place update for the same location where /\x2f we don't send popstate. window.dispatchEvent(new PopStateEvent("pp:statechange")); } } static getTitleForIllust(mediaInfo) { if(mediaInfo == null) return null; let pageTitle = ""; if(!helpers.mediaId.isLocal(mediaInfo.mediaId)) { /\x2f For Pixiv images, use the username and title, and indicate if the image is bookmarked. /\x2f We don't show bookmarks in the title for local images, since it's less useful. if(mediaInfo.bookmarkData) pageTitle += "★"; pageTitle += mediaInfo.userName + " - " + mediaInfo.illustTitle; return pageTitle; } else { /\x2f For local images, put the filename at the front, and the two parent directories after /\x2f it. For example, "books/Book Name/001" will be displayed a "001 - books/Book Name". /\x2f This is consistent with the title we use in the search view. let {id} = helpers.mediaId.parse(mediaInfo.mediaId); let name = helpers.strings.getPathSuffix(id, 1, 0); /\x2f filename let parent = helpers.strings.getPathSuffix(id, 2, 1); /\x2f parent directories pageTitle += \`\${name} - \${parent}\`; } return pageTitle; } static setTitle(mediaInfo) { let pageTitle = helpers.getTitleForIllust(mediaInfo) ?? "Loading..."; helpers.setPageTitle(pageTitle); } static setIcon({vview=false}={}) { if(ppixiv.native || vview) helpers.setPageIcon(ppixiv.resources['resources/vview-icon.png']); else helpers.setPageIcon(ppixiv.resources['resources/regular-pixiv-icon.png']); } static setTitleAndIcon(mediaInfo) { helpers.setTitle(mediaInfo) helpers.setIcon() } /\x2f Return 1 if the given keydown event should zoom in, -1 if it should zoom /\x2f out, or null if it's not a zoom keypress. static isZoomHotkey(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; } } /\x2f A convenience wrapper for setTimeout: export class Timer { constructor(func) { this.func = func; } _runFunc = () => { this.func(); } clear() { if(this.id == null) return; realClearTimeout(this.id); this.id = null; } set(ms) { this.clear(); this.id = realSetTimeout(this._runFunc, ms); } }; /\x2f Polyfill movementX and movementY for iOS < 17. export class PointerEventMovement { constructor() { /\x2f If the browser supports movementX (everyone except for iOS Safari), this isn't /\x2f needed. if("movementX" in new PointerEvent("test")) return; this.last_pointer_positions = {}; window.addEventListener("pointerdown", (e) => this.pointerdown(e), { capture: true }); window.addEventListener("pointermove", (e) => this.pointerdown(e), { capture: true }); window.addEventListener("pointerup", (e) => this.pointerup(e), { capture: true }); window.addEventListener("pointercancel", (e) => this.pointerup(e), { capture: true }); } pointerdown(e) { /\x2f If this is the first event for this pointerId, store the current position. Otherwise, /\x2f store the previous position. let previousX = this.last_pointer_positions[e.pointerId]?.x ?? e.screenX; let previousY = this.last_pointer_positions[e.pointerId]?.y ?? e.screenY; this.last_pointer_positions[e.pointerId] = { x: e.screenX, y: e.screenY }; e.movementX = e.screenX - previousX; e.movementY = e.screenY - previousY; } pointerup(e) { delete this.last_pointer_positions[e.pointerId]; e.movementX = e.movementY = 0; } } /\x2f This is like pointer_listener, but for watching for keys being held down. /\x2f This isn't meant to be used for single key events. class GlobalKeyListener { constructor() { this.keys_pressed = new Set(); this.listeners = new Map(); /\x2f by key /\x2f Listen to keydown on bubble, so we don't see key presses that were stopped /\x2f by the original target, but listen to keyup on capture. window.addEventListener("keydown", (e) => { if(this.keys_pressed.has(e.key)) return; this.keys_pressed.add(e.key); this._callListenersForKey(e.key, true); }); window.addEventListener("keyup", (e) => { if(!this.keys_pressed.has(e.key)) return; this.keys_pressed.delete(e.key); this._callListenersForKey(e.key, false); }, true); window.addEventListener("blur", (e) => { this.releaseAllKeys(); }); /\x2f If the context menu is shown, release all keys, since browsers forget to send /\x2f keyup events when the context menu is open. window.addEventListener("contextmenu", async (e) => { /\x2f This is a pain. We need to handle this event as late as possible, to let /\x2f all other handlers have a chance to preventDefault. If we check it now, /\x2f contextmenu handlers (like blocking_context_menu_until_timer) can be registered /\x2f after us, and we won't see their preventDefault. /\x2f /\x2f This really wants an option for event listeners that causes it to be run after /\x2f other event handlers, but doesn't allow it to preventDefault, for event handlers /\x2f that specifically want to know if an event ended up being prevented. But that /\x2f doesn't exist, so instead we just sleep to exit to the event loop, and look at /\x2f the event after it's completed. await helpers.other.sleep(0); if(e.defaultPrevented) return; this.releaseAllKeys(); }); } releaseAllKeys() { for(let key of this.keys_pressed) this._callListenersForKey(key, false); this.keys_pressed.clear(); } _getListenersForKey(key, { create=false }={}) { if(!this.listeners.has(key)) { if(!create) return []; this.listeners.set(key, new Set); } return this.listeners.get(key); } _registerListener(key, listener) { let listeners_for_key = this._getListenersForKey(key, { create: true }); listeners_for_key.add(listener); /\x2f If key is already pressed, run the callback. Defer this so we don't call /\x2f it while the caller is still registering. realSetTimeout(() => { /\x2f Stop if the listener was unregistered before we got here. if(!this._getListenersForKey(key).has(listener)) return; if(this.keys_pressed.has(key)) listener.keyChanged(true); }, 0); } _unregisterListener(key, listener) { let listeners_for_key = this._getListenersForKey(key, { create: false }); if(listeners_for_key) listeners_for_key.delete(listener); } _callListenersForKey(key, down) { let listeners_for_key = this._getListenersForKey(key, { create: false }); if(listeners_for_key == null) return; for(let key_listener of listeners_for_key.values()) key_listener.keyChanged(down); }; } export class KeyListener { static singleton = null; constructor(key, callback, {signal=null}={}) { if(KeyListener.singleton == null) KeyListener.singleton = new GlobalKeyListener(); this.callback = callback; this.pressed = false; KeyListener.singleton._registerListener(key, this); if(signal) { signal.addEventListener("abort", (e) => { KeyListener.singleton._unregisterListener(key, this); }); } } keyChanged = (pressed) => { if(this.pressed == pressed) return; this.pressed = pressed; this.callback(pressed); } } /\x2f A helper to run an async function and abort a previous call if it's still running. /\x2f /\x2f async function func({args, signal}) { signal.throwIfAborted(); } /\x2f this.runner = new GuardedRunner(); /\x2f this.runner.call(func, { args }); /\x2f this.runner.call(func, { args }); /\x2f aborts the previous call /\x2f this.runner.abort(); /\x2f also aborts the previous call /\x2f await this.runner.promise; /\x2f wait for the most recent call export class GuardedRunner { constructor({signal}={}) { this._abort = null; this._promise = null; if(signal) signal.addEventListener("abort", () => this.abort()); } call(func, {...args}) { /\x2f If a previous call is still running, abort it. if(this._abort) this.abort(); /\x2f Create an AbortController for this call. let abort = this._abort = new AbortController(); args = { ...args, signal: abort.signal }; /\x2f Run the function. let promise = this._promise = this._runIgnoringAborts(func, args); promise.finally(() => { if(this._abort == abort) this._abort = null; if(this._promise == promise) this._promise = null; }); return promise; } /\x2f If a call is running, return its promise, otherwise return null. get promise() { return this._promise; } /\x2f Return true if a call is running. get isRunning() { return this._abort != null; } async _runIgnoringAborts(func, args) { try { return await func(args); } catch(e) { if(e.name == "AbortError") return; throw e; } } abort() { if(this._abort) { this._abort.abort(); /\x2f Clear this._abort synchronously so isRunning is false when we return, and doesn't /\x2f have to wait for the exception to resolve. this._abort = null; this._promise = null; } } } export class FixedDOMRect extends DOMRect { constructor(left, top, right, bottom) { super(left, top, right-left, bottom-top); } /\x2f Allow editing the rect as a pair of x1,y1/x2,y2 coordinates, which is more natural /\x2f than x,y and width,height. x1 and y1 can be greater than x2 and y2 if the rect is /\x2f inverted (width or height are negative). get x1() { return this.x; } get y1() { return this.y; } get x2() { return this.x + this.width; } get y2() { return this.y + this.height; } set x1(value) { this.width += this.x - value; this.x = value; } set y1(value) { this.height += this.y - value; this.y = value; } set x2(value) { this.width = value - super.x; } set y2(value) { this.height = value - super.y; } get middleHorizontal() { return (super.right + super.left) / 2; } get middleVertical() { return (super.top + super.bottom) / 2; } /\x2f Return a new FixedDOMRect with the edges pushed outwards by value. extendOutwards(value) { return new FixedDOMRect( this.left - value, this.top - value, this.right + value, this.bottom + value ) } /\x2f Crop this rect to fit within outer. cropTo(outer) { return new FixedDOMRect( helpers.math.clamp(this.x1, outer.x1, outer.x2), helpers.math.clamp(this.y1, outer.y1, outer.y2), helpers.math.clamp(this.x2, outer.x1, outer.x2), helpers.math.clamp(this.y2, outer.y1, outer.y2), ); } } /\x2f Add: /\x2f /\x2f await controller.signal.wait() /\x2f /\x2f to wait for an AbortSignal to be aborted. AbortSignal.prototype.wait = function() { if(this.aborted) return; if(this._promise == null) { this._promise = new Promise((accept) => { this._promise_accept = accept; }); this.addEventListener("abort", (e) => { this._promise_accept(); }, { once: true }); } return this._promise; }; /\x2f A helper for exponential backoff delays. export class SafetyBackoffTimer { constructor({ /\x2f Reset the backoff after this much time elapses without requiring a backoff. resetAfter=60, /\x2f The maximum backoff delay time, in seconds. maxBackoff=30, /\x2f The exponent for backoff. Each successive backup waits for exponent^error count. exponent=1.5, }={}) { this.resetAfterMs = resetAfter*1000; this.maxBackoffTime = maxBackoff*1000; this.exponent = exponent; this.reset(); } reset() { this.reset_at = Date.now() + this.resetAfterMs; this.backoff_count = 0; } async wait() { /\x2f If enough time has passed without a backoff, reset. if(Date.now() >= this.reset_at) this.reset(); this.reset_at = Date.now() + this.resetAfterMs; this.backoff_count++; let delay_ms = Math.pow(this.exponent, this.backoff_count) * 1000; delay_ms = Math.min(delay_ms, this.maxBackoffTime); console.log("wait for", delay_ms); await helpers.other.sleep(delay_ms); } }; /\x2f This is a wrapper to treat a classList as a set of flags that can be monitored. /\x2f /\x2f let flags = ClassFlags(element); /\x2f flags.set("enabled", true); /\x2f class="enabled" /\x2f flags.set("selected", true); /\x2f class="enabled selected" /\x2f flags.set("enabled", false); /\x2f class="selected" /\x2f /\x2f export class ClassFlags extends EventTarget { /\x2f This class can be used on anything, but it's normally used on for document-wide /\x2f flags. static get get() { if(this.singleton == null) this.singleton = new ClassFlags(document.documentElement); return this.singleton; } constructor(element) { super(); this.element = element; /\x2f Use a MutationObserver, so we'll see changes whether they're made by us or something /\x2f else. let observer = new MutationObserver((mutations) => { /\x2f If we have multiple mutation records, we only need to process the first one, comparing /\x2f the first oldValue to the current value. let mutation = mutations[0]; let old_classes = mutation.oldValue ?? ""; let old_set = new Set(old_classes.split(" ")); let new_set = this.element.classList; for(let name of new_set) if(!old_set.has(name)) this.broadcast(name, true); for(let name of old_set) if(!new_set.contains(name)) this.broadcast(name, false); }); observer.observe(element, { attributeFilter: ["class"], attributeOldValue: true }); } get(name) { return this.element.classList.contains(name); } set(name, value) { /\x2f Update the class. The mutation observer will handle broadcasting the change. helpers.html.setClass(this.element, name, value); return true; } /\x2f Dispatch an event for a change to the given key. broadcast(name, value) { let e = new Event(name); e.value = value; this.dispatchEvent(e); } }; /\x2f A simple wakeup event. class WakeupEvent { constructor() { this._signal = new AbortController(); } /\x2f Wait until a call to wake(). async wait() { await this._signal.signal.wait(); } /\x2f Wake all current waiters. wake() { this._signal.abort(); this._signal = new AbortController(); } }; /\x2f This keeps track of open UI that the user is interacting with which should /\x2f prevent us from auto-advancing images in the slideshow. This allows us to /\x2f pause the slideshow or prevent it from advancing while the context menu or /\x2f settings are open. export class OpenWidgets extends EventTarget { static get singleton() { if(this._singleton == null) this._singleton = new this; return this._singleton; } constructor() { super(); this._openWidgets = new Set(); this.event = new WakeupEvent(); } /\x2f If true, there are no open widgets or dialogs that should prevent the image from /\x2f changing automatically. get empty() { return this._openWidgets.size == 0; } /\x2f A shortcut to add or remove a widget. set(widget, value) { if(value) this.add(widget); else this.remove(widget); } /\x2f We're also an event target, so you can register to find out when dialogs are opened /\x2f and closed. _broadcastChanged() { this.dispatchEvent(new Event("changed")); } /\x2f Add an open widget to the list. add(widget) { let wasEmpty = this.empty; this._openWidgets.add(widget); if(wasEmpty) this._broadcastChanged(); } /\x2f Remove an open UI from the list, possibly waking up callers to waitUntilEmpty. async remove(widget) { if(!this._openWidgets.has(widget)) return; this._openWidgets.delete(widget); if(this.event.size > 0) return; /\x2f Another widget might be added immediately after this one is removed, so don't wake /\x2f listeners immediately. Yield to the event loop, and check after anything else on /\x2f the stack has finished. await null; /\x2f Let any listeners know that our empty status has changed. Do this before checking /\x2f if we're empty, in case this causes somebody to open another dialog. this._broadcastChanged(); if(this.event.size > 0) return; this.event.wake(); } async waitUntilEmpty() { while(!this.empty) await this.event.wait(); } /\x2f Return all open widgets. get_all() { return this._openWidgets; } } /\x2f These are used all over the place, so we add them here to avoid having to import them /\x2f everywhere. Eventually this module should just be a collection of these modules and /\x2f everything else should be in submodules. helpers.math = math; helpers.strings = strings; helpers.html = html; helpers.other = other; helpers.args = Args; helpers.mediaId = mediaId; helpers.pixiv = pixiv; helpers.pixivRequest = pixivRequest; /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/helpers.js `), "/vview/misc/hide-mouse-cursor-on-idle.js": loadBlob("application/javascript", `/\x2f A singleton that keeps track of whether the mouse has moved recently. /\x2f /\x2f Dispatch "mouseactive" on window when the mouse has moved recently and /\x2f "mouseinactive" when it hasn't. import { helpers } from '/vview/misc/helpers.js'; class TrackMouseMovement { constructor() { TrackMouseMovement._singleton = this; this.forceHiddenUntil = null; this.setMouseAnchorTimeout = -1; this.lastMousePos = null; window.addEventListener("mousemove", this.onmousemove, { capture: true }); } static _singleton = null; static get singleton() { return TrackMouseMovement._singleton; } /\x2f True if the mouse is stationary. This corresponds to the mouseinactive event. get stationary() { return !this._active; } /\x2f Briefly pretend that the mouse is inactive. /\x2f /\x2f This is done when releasing a zoom to prevent spuriously showing the mouse cursor. simulateInactivity() { this.forceHiddenUntil = Date.now() + 150; this.idle(); } onmousemove = (e) => { let mousePos = [e.screenX, e.screenY]; this.lastMousePos = mousePos; if(!this.anchorPos) this.anchorPos = this.lastMousePos; /\x2f Cleare the anchorPos timeout when the mouse moves. this.clearMouseAnchorTimeout(); /\x2f If we're forcing the cursor inactive for a while, stop. if(this.forceHiddenUntil && this.forceHiddenUntil > Date.now()) return; /\x2f Show the cursor if the mouse has moved far enough from the current anchorPos. let distanceMoved = helpers.math.distance({x: this.anchorPos[0], y: this.anchorPos[1]}, {x: mousePos[0], y: mousePos[1]}); if(distanceMoved > 10) { this.markMouseActive(); return; } /\x2f If we see mouse movement that isn't enough to cause us to display the cursor /\x2f and we don't see more movement for a while, reset anchorPos so we discard /\x2f the movement we saw. this.setMouseAnchorTimeout = realSetTimeout(() => { this.setMouseAnchorTimeout = -1; this.anchorPos = this.lastMousePos; }, 500); } /\x2f Remove the setMouseAnchorTimeout timeout, if any. clearMouseAnchorTimeout() { if(this.setMouseAnchorTimeout == -1) return; realClearTimeout(this.setMouseAnchorTimeout); this.setMouseAnchorTimeout = -1; } _removeTimer() { if(!this.timer) return; realClearTimeout(this.timer); this.timer = null; } /\x2f The mouse has been active recently. Send mouseactive if the state is changing, /\x2f and schedule the next time it'll become inactive. markMouseActive() { /\x2f When showing the cursor, snap the mouse movement anchor to the last seen position /\x2f and remove any anchorPos timeout. this.anchorPos = this.lastMousePos; this.clearMouseAnchorTimeout(); this._removeTimer(); this.timer = realSetTimeout(this.idle, 500); if(!this._active) { this._active = true; window.dispatchEvent(new Event("mouseactive")); } } /\x2f The timer has expired (or was forced to expire). idle = () => { this._removeTimer(); if(this._active) { this._active = false; window.dispatchEvent(new Event("mouseinactive")); } } } /\x2f Hide the mouse cursor when it hasn't moved briefly, to get it out of the way. /\x2f This only hides the cursor over element. export class HideMouseCursorOnIdle { static instances = new Set(); static simulateInactivity() { TrackMouseMovement.singleton.simulateInactivity(); } constructor(element) { if(ppixiv.mobile) return; HideMouseCursorOnIdle.addStyle(); HideMouseCursorOnIdle.instances.add(this); this.track = new TrackMouseMovement(); this.element = element; window.addEventListener("mouseactive", () => this._refreshHideCursor()); window.addEventListener("mouseinactive", () => this._refreshHideCursor()); ppixiv.settings.addEventListener("no-hide-cursor", HideMouseCursorOnIdle.updateFromSettings); HideMouseCursorOnIdle.updateFromSettings(); } static disabled_by = new Set(); static addStyle() { if(HideMouseCursorOnIdle.globalStyle) return; /\x2f Create the style to hide the mouse cursor. This hides the mouse cursor on .hide-cursor, /\x2f and forces everything underneath it to inherit it. This prevents things further down /\x2f that set their own cursors from unhiding it. /\x2f /\x2f This also works around a Chrome bug: if the cursor is hidden, and we show the cursor while /\x2f simultaneously animating an element to be visible over it, it doesn't recognize /\x2f hovers over the element until the animation completes or the mouse moves. It /\x2f seems to be incorrectly optimizing out hover checks when the mouse is hidden. /\x2f Work around this by hiding the cursor with an empty image instead of cursor: none, /\x2f so it doesn't know that the cursor isn't visible. /\x2f /\x2f This is set as a separate style, so we can disable it selectively. This allows us to /\x2f globally disable mouse hiding. This used to be done by setting a class on body, but /\x2f that's slower and can cause animation hitches. let style = helpers.html.addStyle("hide-cursor", \` .hide-cursor { cursor: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="), none !important; } .hide-cursor * { cursor: inherit !important; } \`); HideMouseCursorOnIdle.globalStyle = style; } static updateFromSettings() { /\x2f If no-hide-cursor is true, disable the style that hides the cursor. We track cursor /\x2f hiding and set the local hide-cursor style even if cursor hiding is disabled, so /\x2f other UI can use it, like video seek bars. HideMouseCursorOnIdle.globalStyle.disabled = !this.isEnabled; } /\x2f Temporarily disable hiding all mouse cursors. source is a key for the UI that's doing /\x2f this, so different UI can disable cursor hiding without conflicting. static enableAll(source) { if(ppixiv.mobile) return; this.disabled_by.delete(source); this.updateFromSettings(); for(let instance of HideMouseCursorOnIdle.instances) instance._refreshHideCursor(); } static disableAll(source) { if(ppixiv.mobile) return; this.disabled_by.add(source); this.updateFromSettings(); for(let instance of HideMouseCursorOnIdle.instances) instance._refreshHideCursor(); } static get mouseStationary() { return this._mouseStationary; } static set mouseStationary(value) { this._mouseStationary = value; } static get isEnabled() { return !ppixiv.settings.get("no-hide-cursor") && this.disabled_by.size == 0; } _refreshHideCursor() { /\x2f cursor-stationary means the mouse isn't moving, whether or not we're hiding /\x2f the cursor when it's stationary. hide-cursor is set to actually hide the cursor /\x2f and UI elements that are hidden with the cursor. let stationary = TrackMouseMovement.singleton.stationary; let hidden = stationary && HideMouseCursorOnIdle.isEnabled; helpers.html.setClass(this.element, "hide-cursor", hidden); helpers.html.setClass(this.element, "show-cursor", !hidden); helpers.html.setClass(this.element, "cursor-stationary", stationary); helpers.html.setClass(this.element, "cursor-active", !stationary); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/hide-mouse-cursor-on-idle.js `), "/vview/misc/image-preloader.js": loadBlob("application/javascript", `/\x2f Handle preloading images. /\x2f /\x2f If we have a reasonably fast connection and the site is keeping up, we can just preload /\x2f blindly and let the browser figure out priorities. However, if we preload too aggressively /\x2f for the connection and loads start to back up, it can cause image loading to become delayed. /\x2f For example, if we preload 100 manga page images, and then back out of the page and want to /\x2f view something else, the browser won't load anything else until those images that we no /\x2f longer need finish loading. /\x2f /\x2f ImagePreloader is told the media ID that we're currently showing, and the ID that we want /\x2f to speculatively load. We'll run loads in parallel, giving the current image's resources /\x2f priority and cancelling loads when they're no longer needed. import LocalAPI from '/vview/misc/local-api.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f The image ResourceLoader singleton. export default class ImagePreloader { /\x2f Return the singleton, creating it if needed. static get singleton() { if(ImagePreloader._singleton == null) ImagePreloader._singleton = new ImagePreloader(); return ImagePreloader._singleton; }; constructor() { /\x2f The _preloader objects that we're currently running. this.preloads = []; /\x2f A queue of URLs that we've finished preloading recently. We use this to tell if /\x2f we don't need to run a preload. this.recentlyPreloadedUrls = []; } /\x2f Set the media ID the user is currently viewing. If mediaId is null, the user isn't /\x2f viewing an image (eg. currently viewing thumbnails). async setCurrentImage(mediaId) { if(this.currentMediaId == mediaId) return; this.currentMediaId = mediaId; this.currentMediaInfo = null; await this.guessPreload(mediaId); if(this.currentMediaId == null) return; /\x2f Get the image data. This will often already be available. let illustInfo = await ppixiv.mediaCache.getMediaInfo(this.currentMediaId); /\x2f Stop if the illust was changed while we were loading. if(this.currentMediaId != mediaId) return; /\x2f Store the illustInfo for currentMediaId. this.currentMediaInfo = illustInfo; this.checkFetchQueue(); } /\x2f Set the media ID we want to speculatively load, which is the next or previous image in /\x2f the current search. If mediaId is null, we don't want to speculatively load anything. async setSpeculativeImage(mediaId) { if(this._speculativeMediaId == mediaId) return; this._speculativeMediaId = mediaId; this._speculativeMediaInfo = null; if(this._speculativeMediaId == null) return; /\x2f Get the image data. This will often already be available. let illustInfo = await ppixiv.mediaCache.getMediaInfo(this._speculativeMediaId); if(this._speculativeMediaId != mediaId) return; /\x2f Stop if the illust was changed while we were loading. if(this._speculativeMediaId != mediaId) return; /\x2f Store the illustInfo for speculativeMediaId. this._speculativeMediaInfo = illustInfo; this.checkFetchQueue(); } /\x2f See if we need to start or stop preloads. We do this when we have new illustration info, /\x2f and when a fetch finishes. checkFetchQueue() { /\x2f console.log("check queue:", this.currentMediaInfo != null, this._speculativeMediaInfo != null); /\x2f Make a list of fetches that we want to be running, in priority order. let wantedPreloads = []; if(this.currentMediaInfo != null) wantedPreloads = wantedPreloads.concat(this._createPreloadersForIllust(this.currentMediaInfo, this.currentMediaId)); if(this._speculativeMediaInfo != null) wantedPreloads = wantedPreloads.concat(this._createPreloadersForIllust(this._speculativeMediaInfo, this._speculativeMediaId)); /\x2f Remove all preloads from wantedPreloads that we've already finished recently. let filteredPreloads = []; for(let preload of wantedPreloads) { if(this.recentlyPreloadedUrls.indexOf(preload.url) == -1) filteredPreloads.push(preload); } /\x2f If we don't want any preloads, stop. If we have any running preloads, let them continue. if(filteredPreloads.length == 0) { /\x2f console.log("Nothing to do"); return; } /\x2f Discard preloads beyond the number we want to be running. If we're loading more than this, /\x2f we'll start more as these finish. let concurrentPreloads = 5; filteredPreloads.splice(concurrentPreloads); /\x2f console.log("Preloads:", filteredPreloads.length); /\x2f If any preload in the list is running, stop. We only run one preload at a time, so just /\x2f let it finish. for(let preload of filteredPreloads) { let activePreload = this._findActivePreloadByUrl(preload.url); if(activePreload != null) return; } /\x2f No preloads are running, so start the highest-priority preload. /\x2f /\x2f updatedPreloadList allows us to run multiple preloads at a time, but we currently /\x2f run them in serial. let updatedPreloadList = []; for(let preload of filteredPreloads) { /\x2f Start this preload. /\x2f console.log("Start preload:", preload.url); let promise = preload.start(); let aborted = false; promise.catch((e) => { if(e.name == "AbortError") aborted = true; }); promise.finally(() => { /\x2f Add the URL to recentlyPreloadedUrls, so we don't try to preload this /\x2f again for a while. We do this even on error, so we don't try to load /\x2f failing images repeatedly. /\x2f /\x2f Don't do this if the request was aborted, since that just means the user /\x2f navigated away. if(!aborted) { this.recentlyPreloadedUrls.push(preload.url); this.recentlyPreloadedUrls.splice(0, this.recentlyPreloadedUrls.length - 1000); } /\x2f When the preload finishes (successful or not), remove it from the list. let idx = this.preloads.indexOf(preload); if(idx == -1) { console.error("Preload finished, but we weren't running it:", preload.url); return; } this.preloads.splice(idx, 1); /\x2f See if we need to start another preload. this.checkFetchQueue(); }); updatedPreloadList.push(preload); break; } /\x2f Cancel preloads in this.preloads that aren't in updatedPreloadList. These are /\x2f preloads that we either don't want anymore, or which have been pushed further down /\x2f the priority queue and overridden. for(let preload of this.preloads) { if(updatedPreloadList.indexOf(preload) != -1) continue; /\x2f console.log("Cancelling preload:", preload.url); preload.cancel(); /\x2f Preloads stay in the list until the cancellation completes. updatedPreloadList.push(preload); } this.preloads = updatedPreloadList; } /\x2f Return the ResourceLoader if we're currently preloading url. _findActivePreloadByUrl(url) { for(let preload of this.preloads) if(preload.url == url) return preload; return null; } /\x2f Return an array of preloaders to load resources for the given illustration. _createPreloadersForIllust(mediaInfo, mediaId) { /\x2f Don't precache muted images. if(ppixiv.muting.anyTagMuted(mediaInfo.tagList)) return []; if(ppixiv.muting.isUserIdMuted(mediaInfo.userId)) return []; /\x2f If this is an animation, preload the ZIP. if(mediaInfo.illustType == 2 && !helpers.mediaId.isLocal(mediaId)) { let results = []; /\x2f Don't preload ZIPs in Firefox. It has a bug in Fetch: when in an incognito window, /\x2f the actual streaming file read in IncrementalReader will stop returning data after a /\x2f couple seconds if it overlaps with this non-streaming read. (It also has another bug: /\x2f this non-streaming read will prevent the unrelated streaming read from streaming, so /\x2f image loading will block until the file finishes loading instead of loading smoothly.) let firefox = navigator.userAgent.indexOf("Firefox/") != -1; if(!firefox) results.push(new FetchResourceLoader(mediaInfo.ugoiraMetadata.originalSrc)); /\x2f Preload the original image too, which ViewerUgoira displays if the ZIP isn't /\x2f ready yet. results.push(new ImgResourceLoader(mediaInfo.mangaPages[0].urls.original)); return results; } /\x2f If this is a video, preload the poster. if(mediaInfo.illustType == "video") return [new ImgResourceLoader(mediaInfo.mangaPages[0].urls.poster) ]; /\x2f Otherwise, preload the images. Preload thumbs first, since they'll load /\x2f much faster. Don't preload local thumbs, since they're generated on-demand /\x2f by the local server and are just as expensive to load as the full image. let results = []; for(let url of mediaInfo.previewUrls) { if(!LocalAPI.shouldPreloadThumbs(mediaId, url)) continue; results.push(new ImgResourceLoader(url)); } /\x2f Preload the requested page. let page = helpers.mediaId.parse(mediaId).page; if(page < mediaInfo.mangaPages.length) { let { url } = mediaInfo.getMainImageUrl(page); results.push(new ImgResourceLoader(url)); } if(!ppixiv.mobile) { /\x2f Preload the remaining pages. for(let p = 0; p < mediaInfo.mangaPages.length; ++p) { if(p == page) continue; /\x2f Stagger loading pages that aren't near the current page. let staggered = p < page - 2 || p >= page + 2; let { url } = mediaInfo.getMainImageUrl(p); results.push(new ImgResourceLoader(url, { staggered })); } } return results; } /\x2f Try to start a guessed preload. /\x2f /\x2f This uses guessImageUrl to try to figure out the image URL earlier. Normally /\x2f we have to wait for the image info request to finish before we have the image URL /\x2f to start loading, but if we can guess the URL correctly then we can start loading /\x2f it immediately. /\x2f /\x2f If mediaId is null, stop any running guessed preload. async guessPreload(mediaId) { if(ppixiv.mobile) return; /\x2f See if we can guess the image's URL from previous info, or if we can figure it /\x2f out from another source. let guessedUrl = null; if(mediaId != null) { guessedUrl = await ppixiv.guessImageUrl.guessUrl(mediaId); if(this.guessedPreload && this.guessedPreload.url == guessedUrl) return; } /\x2f Cancel any previous guessed preload. if(this.guessedPreload) { this.guessedPreload.cancel(); this.guessedPreload = null; } /\x2f Start the new guessed preload. if(guessedUrl) { this.guessedPreload = new ImgResourceLoader(guessedUrl, { onerror: () => { /\x2f The image load failed. Let guessImageUrl know. /\x2f console.info("Guessed image load failed"); ppixiv.guessImageUrl.guessedUrlIncorrect(mediaId); }, }); this.guessedPreload.start(); } } } /\x2f The time in milliseconds to delay loading low-priority images. const StaggerDelay = 1500; /\x2f A base class for fetching a single resource: class ResourceLoader { static lastLoadFinishTime = null; constructor({ staggered=false, }={}) { this.staggered = staggered; this.abortController = new AbortController(); } get aborted() { return this.abortController.signal.aborted; } async start() { await this._waitForStaggerDelay(); this._startedAt = Date.now(); } /\x2f If this load is staggered, sleep until StaggerDelay after the previous load finished. async _waitForStaggerDelay() { if(ResourceLoader.lastLoadFinishTime == null) return; /\x2f Always stagger preload if the page isn't visible. let staggerLoad = this.staggered; if(document.visibilityState == "hidden") staggerLoad = true; if(!staggerLoad) return; let timeSinceLastLoad = Date.now() - ResourceLoader.lastLoadFinishTime; let ms = StaggerDelay - timeSinceLastLoad; if(ms > 0) { /\x2f console.log("Delaying staggered load by", ms); await helpers.other.sleep(ms); } } /\x2f This is called by start() once the load finishes. _loadFinished() { if(this.aborted) return; /\x2f Update lastLoadFinishTime. /\x2f /\x2f We don't want to set lastLoadFinishTime if this load came out of cache. It didn't /\x2f actually cause a network load, so it shouldn't cause us to delay staggered loads. /\x2f This way, if preloading restarts we won't go back to the beginning and stagger every /\x2f page load even though nothing is actually happening. /\x2f /\x2f The browser won't tell us this. Just assume it came out of cache if it completed /\x2f quickly, which is close enough for this. Don't set the threshold too low, since there /\x2f can be delayed even with memory cache. let loadTook = Date.now() - this._startedAt; let wasCached = loadTook < 250; if(!wasCached) ResourceLoader.lastLoadFinishTime = Date.now(); } /\x2f Cancel the fetch. cancel() { this.abortController.abort(); } } /\x2f Load a single image with : class ImgResourceLoader extends ResourceLoader { constructor(url, { onerror=null, ...args }={}) { super({...args}); this.url = url; this.onerror = onerror; console.assert(url); } /\x2f Start the fetch. This should only be called once. async start() { if(this.url == null) return; await super.start(); if(this.aborted) return; let img = document.createElement("img"); img.src = this.url; let result = await helpers.other.waitForImageLoad(img, this.abortController.signal); if(result == "failed" && this.onerror) this.onerror(); this._loadFinished(); } } /\x2f Load a resource with fetch. class FetchResourceLoader extends ResourceLoader { constructor(url, args) { super(args); this.url = url; console.assert(url); } async start() { if(this.url == null) return; await super.start(); if(this.aborted) return; let request = helpers.pixivRequest.sendPixivRequest({ url: this.url, method: "GET", signal: this.abortController.signal, }); /\x2f Wait for the body to download before completing. Ignore errors here (they'll /\x2f usually be cancellations). try { request = await request; await request.text(); } catch(e) { } this._loadFinished(); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/image-preloader.js `), "/vview/misc/image-translation.js": loadBlob("application/javascript", `/\x2f Image translation /\x2f /\x2f This needs GM.xmlHttpRequest to fetch Pixiv images and make API requests. It won't /\x2f work in Safari, since it doesn't support @connect. import { helpers } from '/vview/misc/helpers.js'; import { downloadPixivImage, sendRequest } from '/vview/util/gm-download.js'; import Widget from '/vview/widgets/widget.js'; import { MenuOptionOptionsSetting, MenuOptionToggleSetting, MenuOptionToggle, MenuOptionRow, MenuOptionButton } from '/vview/widgets/menu-option.js'; /\x2f The cotrans script seems to have no limit to the number of requests it'll start, but /\x2f for sanity we set a request limit. const MaxParallelTranslationRequests = 3; /\x2f Map from our settings to API fields: const AllSettings = { /\x2f If true, translate using a lower resolution Pixiv image. This is usually good enough, /\x2f and is much faster. Unfortunately there's no way to provide a low-res image for translation /\x2f and to receive a high-res result, so this causes the text to also be lower resolution. translation_low_res: "forceLowRes", /\x2f S: 1024 /\x2f M: 1536 /\x2f L: 2048 /\x2f XL: 2560 translation_size: "size", /\x2f gpt3.5, youdao, baidu, google, deepl, papago, offline translation_translator: "translator", /\x2f auto, h, v translation_direction: "direction", /\x2f CHS, CHT, JPN, ENG, KOR, VIN, CSY, NLD, FRA, DEU, HUN, ITA, PLK, PTB, ROM, RUS, UKR, ESP, TRK translation_language: "target_language", }; class TranslationError extends Error { }; export default class ImageTranslations extends EventTarget { constructor() { super(); this._displayedMediaId = null; this._translateMediaIds = new Set(); /\x2f This contains URLs to inpaint images, null if translation succeeded but was blank, or /\x2f exceptions for failed translations. this._translations = new Map(); this._translationRequests = new Map(); this._settingsToId = new Map(); this._mediaIdSettingsOverrides = new Map(); /\x2f Start translations if needed when settings change. for(let settingsKey of Object.keys(AllSettings)) { ppixiv.settings.addEventListener(settingsKey, () => { this._checkTranslationQueue(); this._callTranslationUrlsListeners(); }); } } /\x2f Return true if image translation is supported. get supported() { return !ppixiv.native && !ppixiv.ios; } /\x2f Fire an event if translation URLs may have changed: we have a new translation, or settings /\x2f have changed. _callTranslationUrlsListeners() { this.dispatchEvent(new Event("translation-urls-changed")); } /\x2f Set the media ID that the viewer is currently displaying. We'll only actively /\x2f request translations for pages on the post that's currently being viewed, and /\x2f cancel pending translations if we navigate away from it. setDisplayedMediaId(mediaId) { this._displayedMediaId = mediaId; this._checkTranslationQueue(); } /\x2f Enable or disable translations for the given media ID. setTranslationsEnabled(mediaId, enabled) { mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); if(enabled) this._translateMediaIds.add(mediaId); else this._translateMediaIds.delete(mediaId); this._checkTranslationQueue(); /\x2f Fire callbacks if we turn translations on or off. this._callTranslationUrlsListeners(); } getTranslationsEnabled(mediaId) { mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); return this._translateMediaIds.has(mediaId); } _checkTranslationQueue() { this._refreshTranslationIndicator(); /\x2f Stop if we're running the maximum number of requests. if(this._translationRequests.size >= MaxParallelTranslationRequests) return; /\x2f Stop if we're not displaying an image that we want translations for. let mediaId = this._displayedMediaId; let firstPageMediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); if(mediaId == null || !this._translateMediaIds.has(firstPageMediaId)) return; /\x2f Get media info for the post we're viewing. If it isn't available yet, request it and /\x2f come back when it's cached. We need full info so we have URLs for manga pages. let mediaInfo = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: true }); if(mediaInfo == null) { ppixiv.mediaCache.getMediaInfo(mediaId, { full: true }).then(() => { console.log("Check queue after fetching info"); this._checkTranslationQueue(); }); return; } /\x2f Make a list of pages for this image in the order we want to load them. Start on the page /\x2f the user is currently on and load to the end, then load backwards to page 0. This tries to /\x2f load images the user is more likely to be viewing soon first. let [_, currentPage] = helpers.mediaId.toIllustIdAndPage(mediaId); let pagesToLoad = []; for(let page = currentPage; page < mediaInfo.pageCount; ++page) pagesToLoad.push(page); for(let page = currentPage-1; page >= 0; --page) pagesToLoad.push(page); for(let page of pagesToLoad) { /\x2f Stop once we've started the maximum number of requests. We'll come back and start /\x2f more as they finish. if(this._translationRequests.size >= MaxParallelTranslationRequests) break; /\x2f XXX: this is settings-specific let pageMediaId = helpers.mediaId.getMediaIdForPage(mediaId, page); /\x2f Skip this page if we already have it, or if a request is already queued. if(this._translations.has(this._getIdForMediaId(pageMediaId)) || this._translationRequests.has(this._getIdForMediaId(pageMediaId))) continue; /\x2f Start this translation. let promise = this._getMediaIdTranslation(pageMediaId, page); this._translationRequests.set(this._getIdForMediaId(pageMediaId), promise); /\x2f Remove the request from the list when the promise finishes. promise.finally(() => { this._translationRequests.delete(this._getIdForMediaId(pageMediaId), promise); }); promise.then(async() => { /\x2f Delay a little before starting more requests, as an extra safety in case /\x2f something is wrong await helpers.other.sleep(250); /\x2f See if we need to start another request when each promise finishes. Only do /\x2f this on success, so we don't get stuck in a loop if the promises are throwing. this._checkTranslationQueue(); return false; }); } this._refreshTranslationIndicator(); } /\x2f Set loadingTranslation if the loading indicator should be visible. _refreshTranslationIndicator() { /\x2f Show the indicator if we want translations for the current image and don't have it yet. let showLoadingIndicator = this._displayedMediaId != null && this.getTranslationsEnabled(this._displayedMediaId) && !this._translations.has(this._getIdForMediaId(this._displayedMediaId)); helpers.html.setDataSet(document.documentElement.dataset, "loadingTranslation", showLoadingIndicator); } async _getMediaIdTranslation(mediaId, page) { console.log(\`Requesting translation for \${mediaId}\`); let mediaInfo = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: true }); console.assert(mediaInfo != null); /\x2f Request the low-res version of the image. let translationUrl; try { translationUrl = await this._translateImage(mediaInfo, page); } catch(e) { /\x2f Only log this as an error if it's something other than a TranslationError, so /\x2f we don't spam stack traces for API errors. let log = \`Error translating \${mediaInfo.mediaId}: \${e.message}\`; if(e instanceof TranslationError) console.log(log); else console.error(log); /\x2f Store the exception as the result. translationUrl = e; } /\x2f If this URL is returned, there's no translation for this image. let blankImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQI12NgYAAAAAMAASDVlMcAAAAASUVORK5CYII="; if(translationUrl == blankImage) translationUrl = null; /\x2f Preload the translation image. Don't wait for this. if(translationUrl != null && !(translationUrl instanceof Error)) helpers.other.preloadImages([translationUrl]); /\x2f Store the translation URL. this._translations.set(this._getIdForMediaId(mediaId), translationUrl); /\x2f Trigger a refresh for this image now that we have its translation image. this._callTranslationUrlsListeners(); } /\x2f Return the translation overlay URL for the given media ID if we have one and translations /\x2f for this image are enabled. getTranslationUrl(mediaId) { /\x2f Don't return the translation URL if translations for this image aren't enabled, even /\x2f if we know it. let firstPageMediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); if(!this._translateMediaIds.has(firstPageMediaId)) return null; let url = this._translations.get(this._getIdForMediaId(mediaId)); if(url instanceof Error) return null; else return url; } /\x2f If an error occurred translating mediaId, return it as a string. Otherwise, return null. getTranslationError(mediaId) { /\x2f Don't display any errors if translations for this image have been turned back off. let firstPageMediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); if(!this._translateMediaIds.has(firstPageMediaId)) return null; let url = this._translations.get(this._getIdForMediaId(mediaId)); if(url instanceof Error) return url.message; else return null; } /\x2f If a translation failed with an error, clear the error so it can be retried. retryTranslation(mediaId) { let id = this._getIdForMediaId(mediaId); let url = this._translations.get(id); if(url instanceof Error) this._translations.delete(id); this._checkTranslationQueue(); this._callTranslationUrlsListeners(); } /\x2f Return current settings for mediaId. _settingsForImage(mediaId) { let settings = { /\x2f "ctd" is the only option for this, so we don't have a setting for it. detector: "default", }; /\x2f If we have overrides for this image, overlay them on top. let overrides = this._mediaIdSettingsOverrides.get(mediaId) ?? {}; for(let [settingsKey, apiKey] of Object.entries(AllSettings)) settings[apiKey] = overrides[settingsKey] ?? ppixiv.settings.get(settingsKey); return settings; } /\x2f Return a settings object for a single media ID. This can be used interchangably with /\x2f ppixiv.settings to edit settings for one media ID. getSettingHandlerForImage(mediaId) { return { get: (settingName) => { return this.getSettingForImage(mediaId, settingName); }, set: (settingName, value) => { this.setSettingForImage(mediaId, settingName, value); this._callTranslationUrlsListeners(); }, /\x2f This isn't used. addEventListener: () => null, }; } /\x2f Get and set settings overrides for a single media ID. Setting names are the same as /\x2f the equivalent regular settings names. Setting an override to the current global setting /\x2f removes the override. These aren't stored between sessions. getSettingForImage(mediaId, settingName) { let defaultValue = ppixiv.settings.get(settingName); let overrides = this._mediaIdSettingsOverrides.get(mediaId); if(overrides == null) return defaultValue; return overrides[settingName] ?? defaultValue; } setSettingForImage(mediaId, settingName, value) { mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); let overrides = this._mediaIdSettingsOverrides.get(mediaId); if(overrides == null) { overrides = {}; this._mediaIdSettingsOverrides.set(mediaId, overrides); } let defaultValue = ppixiv.settings.get(settingName); if(value == defaultValue) delete overrides[settingName]; else overrides[settingName] = value; this._checkTranslationQueue(); } /\x2f Return a string identifying a specific set of settings. _idForSettings(settings) { settings = JSON.stringify(settings); let settingsId = this._settingsToId.get(settings); if(settingsId != null) return settingsId; settingsId = this._settingsToId.size; this._settingsToId.set(settings, settingsId); return settingsId; } /\x2f Get the ID to use for storing the given media ID in _translatedUrls and _translationRequests. /\x2f This ties them to the current set of settings, so we'll request new translation images if settings /\x2f change, and not need to request them again if the settings change is reverted. _getIdForMediaId(mediaId) { let settings = this._settingsForImage(mediaId); let settingsId = this._idForSettings(settings); return \`\${mediaId}|\${settingsId}\`; } /\x2f Request an image translation. Return the translation URL, or an Exception object on error. async _translateImage(mediaInfo, page) { let pageMediaId = helpers.mediaId.getMediaIdForPage(mediaInfo.mediaId, page); let settings = this._settingsForImage(mediaInfo.mediaId); let { size, translator, direction, detector, target_language, forceLowRes } = settings; let { url } = mediaInfo.getMainImageUrl(page, { forceLowRes }); url = helpers.pixiv.adjustImageUrlHostname(url); /\x2f Download the image. console.log(\`Downloading image for translation: \${url}\`); let file = await downloadPixivImage(url); console.log(\`Got image: \${url}\`); /\x2f Run preprocessing. This isn't needed if we're using the low-res image,. if(!forceLowRes) file = await this._preprocessImage(file); let translationApiUrl = ppixiv.settings.get("translation_api_url") + '/task/upload/v1'; let response; try { console.log(\`Sending image for translation: \${url}\`); response = await sendRequest({ url: translationApiUrl, method: "POST", responseType: "text", formData: { size, translator, direction, detector, target_language, retry: "false", file, }, }); } catch(e) { throw new TranslationError(e); } response = JSON.parse(response); /\x2f We expect to either get a request ID, an error, or a translation result. let { id, error, translation_mask } = response; if(error != null) throw new TranslationError(\`Translation error for \${pageMediaId}: \${error}\`); if(translation_mask != null) { console.log(\`Cached translation result for \${pageMediaId}: \${translation_mask}\`); return translation_mask; } if(id == null) { /\x2f We didn't get anything, so we don't understand this response. throw new TranslationError(\`Unexpected translation response for \${pageMediaId}:\`, response); } /\x2f Open the queue socket to wait for the result. let websocket = new WebSocket(\`wss:/\x2fapi.cotrans.touhou.ai/task/\${id}/event/v1\`); if(!await helpers.other.waitForWebSocketOpened(websocket)) throw new TranslationError("Couldn't connect to translation socket"); /\x2f Handle messages from the socket. try { while(1) { let data = await helpers.other.waitForWebSocketMessage(websocket); if(data == null) throw new TranslationError(\`Translation socket closed without a result: \${pageMediaId}\`);; switch(data.type) { case "status": /\x2f console.log(\`Translation status: \${data.status}\`); continue; case "pending": /\x2f Our position in the queue changed. continue; case "result": console.log(\`Translation result for \${pageMediaId}: \${data.result.translation_mask}\`); return data.result.translation_mask; case "error": throw new TranslationError(\`Translation error for \${pageMediaId}: \$[data.error}\`); case "not_found": /\x2f The ID is unknown. This is either a bug or a server problem. throw new TranslationError(\`Translation error for \${pageMediaId}: ID not found\`); default: /\x2f Ignore messages that we don't understand. console.log(\`Unknown translation queue message for \${pageMediaId}}:\`, data); continue; } } } finally { websocket.close(); } } async _preprocessImage(data) { /\x2f We don't propagate the MIME type or URL here. Figure out if this image is already a /\x2f JPEG. We'll reencode other images (PNG and GIF) to JPEG regardless of image size. let u8 = new Uint8Array(data); let isJpeg = u8[0] == 0xFF && u8[1] == 0xD8 && u8[2] == 0xFF && u8[3] == 0xE0; /\x2f Load the image to get its resolution. let blob = new Blob([data]); let blobUrl = URL.createObjectURL(blob); try { let img = document.createElement("img"); img.src = blobUrl; let result = await helpers.other.waitForImageLoad(img); if(result == "failed") { console.log(\`Image load failed\`); return null; } /\x2f Reduce the image dimensions to the max size. let width = img.naturalWidth; let height = img.naturalHeight; let maxSize = 2048; let resizeBy = 1; resizeBy = Math.min(resizeBy, maxSize / width); resizeBy = Math.min(resizeBy, maxSize / height); /\x2f If this image is already a JPEG and doesn't need to be downscaled, just use /\x2f the original image data. if(resizeBy == 1 && isJpeg) return data; /\x2f Draw the image into a canvas. let canvas = document.createElement("canvas"); canvas.width = Math.round(width * resizeBy); canvas.height = Math.round(height * resizeBy); let context = canvas.getContext("2d"); context.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, 0, 0, canvas.width, canvas.height); /\x2f Encode to a JPEG. They encode much more quickly than PNGs and there's no reason to spend /\x2f time sending a big PNG. return await helpers.other.canvasToBlob(canvas, { type: "image/jpeg", quality: 0.75 }); } finally { URL.revokeObjectURL(blobUrl); } } /\x2f Return a canvas with the given image and its translation composited, or null if we weren't /\x2f able to load a translation. async getTranslatedImage(mediaId) { /\x2f Translations must already be enabled for this post. if(!this.getTranslationsEnabled(mediaId)) return null; let [_, page] = helpers.mediaId.toIllustIdAndPage(mediaId); let mediaInfo = await ppixiv.mediaCache.getMediaInfo(mediaId, { full: true }); /\x2f Even if translations are using the low-res image, download the full image for saving. let { url } = mediaInfo.getMainImageUrl(page); /\x2f Wait for the translation to complete if needed. await this.waitForTranslation(mediaId); /\x2f The translation URL will be null if there's no translated text on this page or if translation failed. let translationUrl = this.getTranslationUrl(mediaId); if(translationUrl == null) return null; /\x2f Composite the images together. let canvas = document.createElement("canvas"); let context = canvas.getContext("2d"); let createdCanvas = false; for(let imageUrl of [ url, translationUrl, ]) { /\x2f Download the image. We need to use downloadPixivImage for both of these images, since /\x2f neither supports CORS. let arrayBuffer = await downloadPixivImage(imageUrl); let blob = new Blob([arrayBuffer]); let imageBlobUrl = URL.createObjectURL(blob); let img = document.createElement("img"); img.src = imageBlobUrl; try { let imageLoadResult = await helpers.other.waitForImageLoad(img); if(imageLoadResult == "failed") { console.log(\`Image load failed: \${imageUrl}\`); return null; } } finally { URL.revokeObjectURL(imageBlobUrl); } /\x2f Set up the canvas when we get the main image. if(!createdCanvas) { createdCanvas = true; canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; } context.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, 0, 0, canvas.width, canvas.height); } return canvas; } /\x2f If translations are enabled for mediaId, wait for the translation result. If translations /\x2f are turned off, return immediately. waitForTranslation(mediaId, { signal }={}) { return new Promise((resolve) => { /\x2f Return true if we should resolve. let isReady = () => { if(!this.getTranslationsEnabled(mediaId)) { console.error(\`Translations not enabled for \${mediaId}\`); return true; } return this._translations.has(this._getIdForMediaId(mediaId)); } /\x2f Just resolve now if the result is already ready. if(isReady()) { resolve(); return; } /\x2f mediamodified will be fired when we get a translation, and also if translations /\x2f are disabled while we're waiting. let cleanupAbort = new AbortController(); ppixiv.mediaCache.addEventListener("mediamodified", (e) => { if(isReady()) { cleanupAbort.abort(); resolve(); } }, { signal: cleanupAbort.signal }); }); } } /\x2f A MenuOptionToggle to toggle translation for an image. export class MenuOptionToggleImageTranslation extends MenuOptionToggle { constructor({ mediaId, ...options }) { super({ label: "Translate this image", onclick: (e) => this.value = !this.value, ...options }); this.mediaId = mediaId; } refresh() { super.refresh(); this.checkbox.checked = this.value; } get value() { return ppixiv.imageTranslations.getTranslationsEnabled(this.mediaId); } set value(value) { ppixiv.imageTranslations.setTranslationsEnabled(this.mediaId, value); this.refresh(); } } /\x2f Show translation errors and allow retrying. export class MenuOptionRetryTranslation extends MenuOptionRow { constructor({ mediaId, ...options }) { super({ label: "There was an error translating this image", onclick: (e) => this.value = !this.value, ...options }); new MenuOptionButton({ icon: "wallpaper", label: "Retry", container: this.root, onclick: () => { ppixiv.imageTranslations.retryTranslation(this.mediaId); }, }); ppixiv.imageTranslations.addEventListener("translation-urls-changed", () => this.refresh(), this._signal); this.mediaId = mediaId; } refresh() { super.refresh(); let error = ppixiv.imageTranslations.getTranslationError(this.mediaId); this.visible = error; } } function createTranslationSettingsWidget({ globalOptions, editOverrides }) { /\x2f If we're editing overrides, use the settings handler for this image. Otherwise, just edit /\x2f settings normally. We access the current media ID directly here (noromal settings pages /\x2f don't need it, so it's not propagated here) and assume the current image won't change /\x2f while a dialog is open. let settings = ppixiv.settings; let displayedMediaId = ppixiv.app.displayedMediaId; if(editOverrides) settings = editOverrides? ppixiv.imageTranslations.getSettingHandlerForImage(displayedMediaId):ppixiv.settings; return { /\x2f Translation settings translateThisImage: () => { /\x2f This is only used if we have a valid media ID. return new MenuOptionToggleImageTranslation({ ...globalOptions, mediaId: displayedMediaId, }); }, translationLanguage: () => { return new MenuOptionOptionsSetting({ ...globalOptions, setting: "translation_language", settings, label: "Language", values: { ENG: "English", CHS: "Chinese (Simplified)", CHT: "Chinese (Traditional)", CSY: "Czech", NLD: "Dutch", FRA: "French", DEU: "German", HUN: "Hungarian", ITA: "Italian", JPN: "Japanese", KOR: "Korean", PLK: "Polish", PTB: "Portuguese (Brazil)", ROM: "Romanian", RUS: "Russian", ESP: "Spanish", TRK: "Turkish", UKR: "Ukrainian", VIN: "Vietnames", ARA: "Arabic", SRP: "Serbian", HRV: "Croatian", }, }); }, translationTranslator: () => { return new MenuOptionOptionsSetting({ ...globalOptions, setting: "translation_translator", settings, label: "Translation engine", values: { "gpt3.5": "GPT3.5", googleL: "Google", youdao: "Youdao", baidu: "Baidu", deepl: "Deepl", papago: "Papago", offline: "Offline", none: "None (remove text)", }, }); }, translationLowRes: () => { return new MenuOptionToggleSetting({ ...globalOptions, setting: "translation_low_res", settings, label: "Use low res image for translations (faster)", }); }, translationSize: () => { return new MenuOptionOptionsSetting({ ...globalOptions, setting: "translation_size", settings, label: "Translation resolution", values: { S: "1024x1024", M: "1536x1536", L: "2048x2048", XL: "2560x2560", }, }); }, translationDirection: () => { return new MenuOptionOptionsSetting({ ...globalOptions, setting: "translation_direction", settings, label: "Text direction", values: { auto: "Automatic", h: "Horizontal", v: "Vertical", }, }); }, retryTranslation: () => { return new MenuOptionRetryTranslation({ ...globalOptions, mediaId: displayedMediaId, }); }, } } /\x2f Create settings widgets. If editOverrides is true, edit settings overrides for the current image. export function createTranslationSettingsWidgets({ globalOptions, editOverrides }) { let settingsWidgets = createTranslationSettingsWidget({ globalOptions, editOverrides }); /\x2f If this is the override settings page, add the explanation header. if(editOverrides) { new Widget({ ...globalOptions, template: \`
These settings will only affect this image, and aren't saved. Settings for all images can be changed from settings.
\`, }); settingsWidgets.translateThisImage(); } settingsWidgets.translationLanguage(); settingsWidgets.translationTranslator(); /\x2f settingsWidgets.translationLowRes(); settingsWidgets.translationSize(); settingsWidgets.translationDirection(); settingsWidgets.retryTranslation(); } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/image-translation.js `), "/vview/misc/key-storage.js": loadBlob("application/javascript", ` /\x2f Originally from https:/\x2fgist.github.com/wilsonpage/01d2eb139959c79e0d9a export default class KeyStorage { constructor(storeName, {upgradeDb=null, version=1}={}) { this._dbName = storeName; this._upgradeDb = upgradeDb; this._storeName = storeName; this._version = version; this._failed = false; } /\x2f Open the database, run func, then close the database. /\x2f /\x2f If you open a database with IndexedDB and then leave it open, like you would with /\x2f any other database, any attempts to add stores (which you can do seamlessly with /\x2f any other database) will permanently wedge the database. We have to open it and /\x2f close it around every op. /\x2f /\x2f If the database can't be opened, func won't be called and null will be returned. async dbOp(func) { /\x2f Stop early if we've already failed, so we don't log an error for each op. if(this._failed) return null; let db; try { db = await this._openDatabase(); } catch(e) { console.log("Couldn't open database:", e); this._failed = true; return null; } try { return await func(db); } finally { db.close(); } } async getDbVersion() { let dbs = await indexedDB.databases(); for(let db of dbs) { if(db.name == this._dbName) return db.version; } return 0; } _openDatabase() { return new Promise((resolve, reject) => { let request = indexedDB.open(this._dbName, this._version); /\x2f If this happens, another tab has the database open. request.onblocked = e => { console.error("Database blocked:", e); }; request.onupgradeneeded = e => { /\x2f If we have a upgradeDb function, let it handle the upgrade. Otherwise, we're /\x2f just creating the initial database and we're not doing anything special with it. let db = e.target.result; if(this._upgradeDb) this._upgradeDb(e); else db.createObjectStore(this._storeName); }; request.onsuccess = e => { let db = e.target.result; resolve(db); }; request.onerror = e => { reject(request.error); }; }); } getStore(db, mode="readwrite") { let transaction = db.transaction(this._storeName, mode); return transaction.objectStore(this._storeName); } static awaitRequest(request) { return new Promise((resolve, reject) => { let abort = new AbortController; request.addEventListener("success", (e) => { abort.abort(); resolve(request.result); }, { signal: abort.signal }); request.addEventListener("error", (e) => { abort.abort(); reject(request.result); }, { signal: abort.signal }); }); } static asyncStoreGet(store, key) { return new Promise((resolve, reject) => { let request = store.get(key); request.onsuccess = e => resolve(e.target.result); request.onerror = reject; }); } async get(key, store) { return await this.dbOp(async (db) => { return await KeyStorage.asyncStoreGet(this.getStore(db), key); }); } /\x2f Retrieve the values for a list of keys. Return a dictionary of {key: value}. async multiGet(keys) { return await this.dbOp(async (db) => { let store = this.getStore(db, "readonly"); let promises = []; for(let key of keys) promises.push(KeyStorage.asyncStoreGet(store, key)); return await Promise.all(promises); }) ?? {}; } static asyncStoreSet(store, key, value) { return new Promise((resolve, reject) => { let request = store.put(value, key); request.onsuccess = resolve; request.onerror = reject; }); } async set(key, value) { return await this.dbOp(async (db) => { return KeyStorage.asyncStoreSet(this.getStore(db), key, value); }); } /\x2f Given a dictionary, set all key/value pairs. async multiSet(data) { return await this.dbOp(async (db) => { let store = this.getStore(db); let promises = []; for(let [key, value] of Object.entries(data)) { let request = store.put(value, key); promises.push(KeyStorage.awaitRequest(request)); } await Promise.all(promises); }); } async multiSetValues(data) { return await this.dbOp(async (db) => { let store = this.getStore(db); let promises = []; for(let item of data) { let request = store.put(item); promises.push(KeyStorage.awaitRequest(request)); } return Promise.all(promises); }); } async delete(key) { return await this.dbOp(async (db) => { let store = this.getStore(db); return KeyStorage.awaitRequest(store.delete(key)); }); } /\x2f Delete a list of keys. async multiDelete(keys) { return await this.dbOp(async (db) => { let store = this.getStore(db); let promises = []; for(let key of keys) { let request = store.delete(key); promises.push(KeyStorage.awaitRequest(request)); } return Promise.all(promises); }); } /\x2f Delete all keys. async clear() { return await this.dbOp(async (db) => { let store = this.getStore(db); await store.clear(); }); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/key-storage.js `), "/vview/misc/local-api.js": loadBlob("application/javascript", `/\x2f Helpers for the local API. import Path from '/vview/util/path.js'; import { helpers } from '/vview/misc/helpers.js'; export default class LocalAPI { static get localUrl() { /\x2f If we're running natively, the API is on the same URL as we are. if(!ppixiv.native) return null; return new URL("/", document.location); } /\x2f Return the URL path used by the UI. static get path() { /\x2f When running natively, the path is just /. if(ppixiv.native) return "/"; else return "/local/"; } static async localPostRequest(pathname, data={}, options={}) { let url = LocalAPI.localUrl; if(url == null) throw Error("Local API isn't enabled"); url.pathname = encodeURI(pathname); let result = await helpers.pixivRequest.sendPixivRequest({ method: "POST", url: url.toString(), responseType: "json", data: JSON.stringify(data), signal: options.signal, }); /\x2f If the result isn't valid JSON, we'll get a null result. if(result == null) result = { error: true, reason: "Invalid response" }; return result; } /\x2f Return true if the local API is enabled. static isEnabled() { return LocalAPI.localUrl != null; } /\x2f Return true if we're running in VVbrowser. static isVVbrowser() { return navigator.userAgent.indexOf("VVbrowser/") != -1; } /\x2f Load image info from the local API. /\x2f /\x2f If refreshFromDisk and this is a local file, ask the server to ignore cache and /\x2f refresh from disk, even if it thinks it's not necessary. static async loadMediaInfo(mediaId, { refreshFromDisk=false }={}) { let mediaInfo = await LocalAPI.localPostRequest(\`/api/illust/\${mediaId}\`, { refresh_from_disk: refreshFromDisk, }); return mediaInfo; } static async loadRecentBookmarkTags() { let result = await LocalAPI.localPostRequest(\`/api/bookmark/tags\`); if(!result.success) { console.log("Error fetching bookmark tag counts"); return; } let tags = []; for(let tag of Object.keys(result.tags)) { /\x2f Skip "untagged". if(tag == "") continue; tags.push(tag); } tags.sort(); return tags; } /\x2f The local data source URL has two parts: the path and the file being viewed (if any). /\x2f The file be absolute or relative to path. /\x2f /\x2f Path is args.hashPath, and file is args.hash.get("file"). /\x2f /\x2f Changes to path result in a new data source, but changes to the file don't. /\x2f /\x2f Examples: /\x2f /\x2f #/images/pictures?path=vacation/day1 /\x2f /\x2f The user searched inside /images/pictures, and is currently viewing the folder /\x2f /images/pictures/vacation/day1. /\x2f /\x2f #/images/pictures?file=vacation/image.jpg /\x2f /\x2f The user searched inside /images/pictures, and is currently viewing the image /\x2f vacation/image.jpg. This case is important: the path hasn't changed, so the data /\x2f source is still the search, so you can mousewheel within the search. static getArgsForId(mediaId, args) { /\x2f If we're navigating from a special page like /similar, ignore the previous /\x2f URL and create a new one. Those pages can have their own URL formats. if(args.path != LocalAPI.path || args.path != "/") { args.path = LocalAPI.path; args.query = new URLSearchParams(); args.hash = new URLSearchParams(); args.hashPath = "/"; } /\x2f The new path to set: let { type, id: path } = helpers.mediaId.parse(mediaId); if(type == "file") { /\x2f If file isn't underneath hashPath, set hashPath to the file's parent directory. if(!args.hashPath || !Path.isRelativeTo(path, args.hashPath)) { let parentFolderMediaId = LocalAPI.getParentFolder(mediaId); args.hashPath = helpers.mediaId.parse(parentFolderMediaId).id;; } /\x2f Put the relative path from hashPath to file in "file". let relativePath = Path.getRelativePath(args.hashPath, path); args.hash.set("file", relativePath); return args; } /\x2f This is a folder. Remove any file in the URL. args.hash.delete("file"); /\x2f Remove the page when linking to a folder. Don't do this for files, since the /\x2f page should be left in place when viewing an image. args.query.delete("p"); args.hashPath = path; return args; } /\x2f Get the local file or folder ID from a URL. /\x2f /\x2f Normally, a URL is a file if a "file" hash arg is present, otherwise it's /\x2f a folder. If getFolder is true, return the folder, ignoring any file argument. static getLocalIdFromArgs(args, { getFolder=false }={}) { /\x2f Combine the hash path and the filename to get the local ID. let root = args.hashPath; let file = args.hash.get("file"); if(file == null || getFolder) return "folder:" + root; /\x2f The file can also be relative or absolute. if(!file.startsWith("/")) file = Path.getChild(root, file) return "file:" + file; } /\x2f Return the API search options and title for the given URL. static getSearchOptionsForArgs(args) { let searchOptions = { }; let title = null; let search_root = helpers.strings.getPathSuffix(args.hashPath, 2); if(args.hash.has("search")) { searchOptions.search = args.hash.get("search"); title = "Search: " + searchOptions.search; } if(args.hash.has("bookmark-tag")) { searchOptions.bookmarked = true; searchOptions.bookmark_tags = args.hash.get("bookmark-tag"); if(searchOptions.bookmark_tags != "") title = \`Bookmarks tagged \${searchOptions.bookmark_tags}\`; else title = \`Untagged bookmarks\`; } /\x2f We always enable bookmark searching if that's all we're allowed to do. else if(args.hash.has("bookmarks") || LocalAPI.localInfo.bookmark_tag_searches_only) { searchOptions.bookmarked = true; title = "Bookmarks"; } if(args.hash.has("type")) { searchOptions.media_type = args.hash.get("type"); if(!title) title = helpers.strings.titleCase(searchOptions.media_type); } if(args.hash.has("aspect-ratio")) { let range = args.hash.get("aspect-ratio"); searchOptions.aspect_ratio = helpers.strings.parseRange(range); } if(args.hash.has("pixels")) { let range = args.hash.get("pixels"); searchOptions.total_pixels = helpers.strings.parseRange(range); } if(title == null) title = "Search"; title += \` inside \${search_root}\`; /\x2f Clear searchOptions if it has no keys, to indicate that we're not in a search. if(Object.keys(searchOptions).length == 0) { searchOptions = null; /\x2f When there's no search, just show the current path as the title. let folder_id = LocalAPI.getLocalIdFromArgs(args, { getFolder: true }); let { id } = helpers.mediaId.parse(folder_id); title = helpers.strings.getPathSuffix(id); } return { searchOptions, title: title }; } /\x2f Given a folder ID, return its parent. If folder_id is the root, return null. static getParentFolder(mediaId) { if(mediaId == null || mediaId == "folder:/") return null; /\x2f mediaId can be a file or a folder. We always return a folder. let { id } = helpers.mediaId.parse(mediaId); let parts = id.split("/"); if(parts.length == 2) return "folder:/"; /\x2f return folder:/, not folder: parts.splice(parts.length-1, 1); return "folder:" + parts.join("/"); } /\x2f Load access info. We always reload when this changes, eg. due to logging in /\x2f or out, so we cache this at startup. static async loadLocalInfo() { if(LocalAPI.localUrl == null) return; this._cachedApiInfo = await LocalAPI.localPostRequest(\`/api/info\`); } static get localInfo() { let info = this._cachedApiInfo; if(LocalAPI.localUrl == null) info = { success: false, code: "disabled" }; return { /\x2f True if the local API is enabled at all. enabled: LocalAPI.localUrl != null, /\x2f True if we're running on localhost. If we're local, we're always logged /\x2f in and we won't show the login/logout buttons. local: info.success && info.local, /\x2f True if we're logged in as a non-guest user. logged_in: info.success && info.username != "guest", /\x2f True if we're logged out and guest access is disabled, so we need to log /\x2f in to continue. loginRequired: !info.success && info.code == 'access-denied', /\x2f True if we can only do bookmark tag searches. bookmark_tag_searches_only: info.tags != null, } } /\x2f Return true if we're running on localhost. If we're local, we're always logged /\x2f in and we won't show the login/logout buttons. static async isLocal() { let info = await LocalAPI.localPostRequest(\`/api/info\`); return info.local; } /\x2f Return true if we should load thumbnails for image viewing. /\x2f /\x2f We normally preload thumbnails for images, so we have something to display immediately /\x2f when we view an image. This is useful on Pixiv, since they have all of their thumbs /\x2f cached and loading them is free. /\x2f /\x2f However, if we're local and running on desktop, the browser is usually running on the /\x2f same PC as the server, and the server doesn't have thumbnails cached, so requesting it /\x2f will cause the image to be decoded and resized in the server. That means we'll just end /\x2f up decoding every image twice if we do this. /\x2f /\x2f If we're local but running on mobile, do preload thumbs. It's important to have images /\x2f viewable at least in preview as quickly as possible to minimize gaps in the mobile UI, /\x2f and the PC running the server is probably much faster than a tablet, which may take some /\x2f time to decode larger images. static shouldPreloadThumbs(mediaId, url) { if(ppixiv.mobile) return true; if(!helpers.mediaId.isLocal(mediaId)) return true; /\x2f If we know the image was viewed in search results recently, it should be cached, so /\x2f there's no harm in using it. We could query whether the URL is cached with fetch's /\x2f cache: only-if-cached argument, but that causes browsers to obnoxiously spam the console /\x2f with errors every time it fails. That doesn't make sense (errors are normal with /\x2f only-if-cached) and the log spam is too annoying to use it here. if(url != null && LocalAPI._wasThumbnailLoadedRecently(url)) return true; /\x2f We're on desktop, the image is local, and the thumbnail hasn't been loaded recently. return false; } /\x2f Return true if we're logged out and guest access is disabled, so we need to log /\x2f in to continue. static async loginRequired() { /\x2f If we're not logged in and guest access is disabled, all API calls will /\x2f fail with access-denied. Call api/info to check this. let info = await LocalAPI.localPostRequest(\`/api/info\`); return !info.success && info.code == 'access-denied'; } /\x2f Return true if we're logged in as a non-guest user. static async loggedIn() { let info = await LocalAPI.localPostRequest(\`/api/info\`); console.log(info); return info.success && info.username != "guest"; } /\x2f Log out if we're logged in, and redirect to the login page. static redirectToLogin() { let query = new URLSearchParams(); query.set("url", document.location.href); /\x2f Replace the current history entry. This pushes any history state to the /\x2f login page. It'll preserve it after logging in and redirecting back here, /\x2f so we'll try to retain it. let loginUrl = "/vview/resources/auth.html?" + query.toString(); window.history.replaceState(history.state, "", loginUrl.toString()); document.location.reload(); } /\x2f Log out and reload the page. static logout() { document.cookie = \`auth_token=; max-age=0; path=/\`; document.location.reload(); } /\x2f This stores searches like SavedSearchTags. It's simpler, since this is the /\x2f only place these searches are added. static addRecentLocalSearch(tag) { let recentTags = ppixiv.settings.get("local_searches") || []; let idx = recentTags.indexOf(tag); if(idx != -1) recentTags.splice(idx, 1); recentTags.unshift(tag); ppixiv.settings.set("local_searches", recentTags); window.dispatchEvent(new Event("recent-local-searches-changed")); } /\x2f Navigate to a search, usually entered into the tag search box. static navigateToTagSearch(tags, { addToHistory=true}={}) { tags = tags.trim(); if(tags.length == 0) tags = null; /\x2f Add this tag to the recent search list. if(addToHistory && tags) LocalAPI.addRecentLocalSearch(tags); /\x2f Run the search. We expect to be on the local data source when this is called. let args = new helpers.args(ppixiv.plocation); console.assert(args.path == LocalAPI.path); if(tags) args.hash.set("search", tags); else args.hash.delete("search"); args.set("p", null); helpers.navigate(args); } static async indexFolderForSimilaritySearch(mediaId) { let { type, id } = helpers.mediaId.parse(mediaId); if(type != "folder") { console.log(\`Not a folder: \${mediaId}\`); return; } let result = await LocalAPI.localPostRequest(\`/api/similar/index\`, { path: id, }); if(!result.success) { ppixiv.message.show(\`Error indexing \${id}: \${result.reason}\`); return; } ppixiv.message.show(\`Begun indexing \${id} for similarity searching\`); } /\x2f Remember that we've loaded a thumbnail this session. static thumbnailWasLoaded(url) { this._thumbnailsLoadedRecently ??= new Set(); this._thumbnailsLoadedRecently.add(url); } /\x2f Return true if we've loaded a thumbnail this session. This is used to optimize image display. static _wasThumbnailLoadedRecently(url) { return this._thumbnailsLoadedRecently && this._thumbnailsLoadedRecently.has(url); } } /\x2f LocalBroadcastChannel implements the same API as BroadcastChannel, but sends messages /\x2f over the local WebSockets connection. This allows sending messages across browsers and /\x2f machines. If the local API isn't enabled, this is just a wrapper around BroadcastChannel. export class LocalBroadcastChannel extends EventTarget { constructor(name) { super(); this.name = name; LocalBroadcastChannelConnection.get.addEventListener(this.name, this.receivedWebSocketsMessage); /\x2f Create a regular BroadcastChannel. Other tabs in the same browser will receive /\x2f messages through this, so they don't need to round-trip through WebSockets. this.broadcastChannel = new BroadcastChannel(this.name); this.broadcastChannel.addEventListener("message", this.receivedBroadcastChannelMessage); } /\x2f Handle a message received over WebSockets. receivedWebSocketsMessage = (e) => { let event = new MessageEvent("message", { data: e.data }); this.dispatchEvent(event); } /\x2f Handle a message received over BroadcastChannel. receivedBroadcastChannelMessage = (e) => { let event = new MessageEvent("message", { data: e.data }); this.dispatchEvent(event); } postMessage(data) { LocalBroadcastChannelConnection.get.send(this.name, data); this.broadcastChannel.postMessage(data); } close() { LocalBroadcastChannelConnection.get.removeEventListener(this.name, this.receivedWebSocketsMessage); this.broadcastChannel.removeEventListener("message", this.receivedBroadcastChannelMessage); } }; /\x2f This creates a single WebSockets connection to the local server. An event is dispatched /\x2f with the name of the channel when a WebSockets message is received. class LocalBroadcastChannelConnection extends EventTarget { static get get() { if(this.singleton == null) this.singleton = new LocalBroadcastChannelConnection(); return this.singleton; } constructor() { super(); /\x2f This is only used if the local API is enabled. if(!LocalAPI.isEnabled()) return; /\x2f If messages are sent while we're still connecting, or if the buffer is full, /\x2f they'll be buffered until we can send it. Buffered messages will be discarded /\x2f if connecting fails. this._sendBuffer = []; this._reconnectionAttempts = 0; /\x2f If we're disconnected, try to reconnect immediately if the window gains focus. window.addEventListener("focus", () => { this._queueReconnect({ reset: true }); }); /\x2f Store a random ID in localStorage to identify this browser. This is sent to the /\x2f WebSockets server, so it knows not to send broadcasts to clients running in the /\x2f same browser, which will receive the messages much faster through a regular /\x2f BroadcastChannel. this._browserId = ppixiv.settings.get("browser_id"); if(this._browserId == null) { this._browserId = helpers.other.createUuid(); ppixiv.settings.set("browser_id", this._browserId); console.log("Assigned broadcast browser ID:", this._browserId); } this.connect(); } connect() { /\x2f Close the connection if it's still open. this.disconnect(); let url = new URL("/ws", LocalAPI.localUrl); url.protocol = document.location.protocol == "https:"? "wss":"ws"; this.ws = new WebSocket(url); this.ws.onopen = this.wsOpened; this.ws.onclose = this.wsClosed; this.ws.onerror = this.wsError; this.ws.onmessage = this.wsMessageReceived; } disconnect() { if(this.ws == null) return; this.ws.close(); this.ws = null; } /\x2f Queue a reconnection after a connection error. If reset is true, reset reconnection /\x2f attempts and attempt to reconnect immediately. _queueReconnect({reset=false}={}) { if(this.ws != null) return; if(!reset && this.reconnectId != null) return; if(reset) { /\x2f Cancel any queued reconnection. if(this.reconnectId != null) { realClearTimeout(this.reconnectId); this.reconnectId = null; } } if(reset) this._reconnectionAttempts = 0; else this._reconnectionAttempts++; this._reconnectionAttempts = Math.min(this._reconnectionAttempts, 5); let reconnectDelay = Math.pow(this._reconnectionAttempts, 2); /\x2f console.log("Reconnecting in", reconnectDelay); this.reconnectId = realSetTimeout(() => { this.reconnectId = null; this.connect(); }, reconnectDelay*1000); } wsOpened = async(e) => { console.log("WebSockets connection opened"); /\x2f Cancel any queued reconnection. if(this.reconnectId != null) { realClearTimeout(this.reconnectId); this.reconnectId = null; } this._reconnectionAttempts = 0; /\x2f Tell the server our browser ID. This is used to prevent sending messages back /\x2f to the same browser. this._sendRaw({ 'command': 'init', 'browser_id': this._browserId, }); /\x2f Send any data that was buffered while we were still connecting. this._sendBufferedData(); } wsClosed = async(e) => { console.log("WebSockets connection closed", e, e.wasClean, e.reason); this.disconnect(); this._queueReconnect(); } /\x2f We'll also get onclose on connection error, so we don't need to _queueReconnect /\x2f here. wsError = (e) => { console.log("WebSockets connection error"); } wsMessageReceived = (e) => { let message = JSON.parse(e.data); if(message.command != "receive-broadcast") { console.error(\`Unknown WebSockets command: \${message.command}\`); return; } let event = new MessageEvent(message.message.channel, { data: message.message.data }); this.dispatchEvent(event); }; /\x2f Send a WebSockets message on the given channel name. send(channel, message) { if(!LocalAPI.isEnabled()) return; let data = { 'command': 'send-broadcast', 'browser_id': this._browserId, 'message': { 'channel': channel, 'data': message, }, }; this._sendBuffer.push(data); this._sendBufferedData(); } /\x2f Send a raw message directly, without buffering. _sendRaw(data) { this.ws.send(JSON.stringify(data, null, 4)); } /\x2f Send data buffered in _sendBuffer. _sendBufferedData() { if(this.ws == null) return; while(this._sendBuffer.length > 0) { /\x2f This API wasn't thought through. It tells us how much data is buffered, but not /\x2f what the maximum buffer size is. If the buffer fills, instead of returning an /\x2f error, it just unceremoniously kills the connection. There's also no event to /\x2f tell us that buffered data has been sent, so you'd have to poll on a timer. It's /\x2f a mess. if(this.ws.bufferedAmount > 1024*1024 || this.ws.readyState != 1) break; /\x2f Send the next buffered message. let data = this._sendBuffer.shift(); this._sendRaw(data); } } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/local-api.js `), "/vview/misc/media-cache-mappings.js": loadBlob("application/javascript", `import { helpers } from '/vview/misc/helpers.js'; export default class MediaCacheMappings { /\x2f Get the mapping from /ajax/user/id/illusts/bookmarks to illust_list.php's keys. static _thumbnailInfoMapIllustList = [ ["illust_id", "id"], ["url", "url"], ["tags", "tags"], ["illust_user_id", "userId"], ["illust_width", "width"], ["illust_height", "height"], ["illust_type", "illustType"], ["illust_page_count", "pageCount"], ["illust_title", "illustTitle"], ["user_profile_img", "profileImageUrl"], ["user_name", "userName"], /\x2f illust_list.php doesn't give the creation date, and it doesn't have the aiType field. [null, "createDate"], [null, "aiType"], ]; static _thumbnailInfoMapRanking = [ ["illust_id", "id"], ["url", "url"], ["tags", "tags"], ["user_id", "userId"], ["width", "width"], ["height", "height"], ["illust_type", "illustType"], ["illust_page_count", "pageCount"], ["title", "illustTitle"], ["profile_img", "profileImageUrl"], ["user_name", "userName"], ["illust_upload_timestamp", "createDate"], /\x2f Rankings don't return aiType, but we fill it in ourself in the data source. ["aiType", "aiType"], ]; /\x2f Partial media info comes from Pixiv search APIs. They all have different formats /\x2f for the same data. Remap it to our standardized format, which uses the same fields /\x2f as full media info. /\x2f /\x2f name URL /\x2f normal /ajax/user/id/illusts/bookmarks /\x2f illust_list illust_list.php /\x2f following bookmark_new_illust.php /\x2f following search.php /\x2f rankings ranking.php /\x2f /\x2f We map each of these to "normal". static remapPartialMediaInfo(mediaInfo, source) { let remappedMediaInfo = null; if(source == "normal") { /\x2f The data is already in the format we want. The only change we make is /\x2f to rename title to illustTitle, to match it up with illust info. if(!("title" in mediaInfo)) { console.warn("Thumbnail info is missing key: title"); } else { mediaInfo.illustTitle = mediaInfo.title; delete mediaInfo.title; } /\x2f Check that all keys we expect exist, and remove any keys we don't know about /\x2f so we don't use them accidentally. let thumbnailInfoMap = this._thumbnailInfoMapRanking; remappedMediaInfo = { }; for(let pair of thumbnailInfoMap) { let key = pair[1]; if(!(key in mediaInfo)) { console.warn("Thumbnail info is missing key:", key); continue; } remappedMediaInfo[key] = mediaInfo[key]; } if(!('bookmarkData' in mediaInfo)) console.warn("Thumbnail info is missing key: bookmarkData"); else { remappedMediaInfo.bookmarkData = mediaInfo.bookmarkData; /\x2f See above. if(remappedMediaInfo.bookmarkData != null) delete remappedMediaInfo.bookmarkData.bookmarkId; } } else if(source == "illust_list" || source == "rankings") { /\x2f Get the mapping for this mode. let thumbnailInfoMap = source == "illust_list"? this._thumbnailInfoMapIllustList: this._thumbnailInfoMapRanking; remappedMediaInfo = { }; for(let pair of thumbnailInfoMap) { let fromKey = pair[0]; let toKey = pair[1]; if(fromKey == null) { /\x2f This is just for illust_list createDate. remappedMediaInfo[toKey] = null; continue; } if(!(fromKey in mediaInfo)) { console.warn("Thumbnail info is missing key:", fromKey); continue; } let value = mediaInfo[fromKey]; remappedMediaInfo[toKey] = value; } /\x2f Make sure that the illust IDs and user IDs are strings. remappedMediaInfo.id = "" + remappedMediaInfo.id; remappedMediaInfo.userId = "" + remappedMediaInfo.userId; /\x2f Bookmark data is a special case. /\x2f /\x2f The old API has is_bookmarked: true, bookmark_id: "id" and bookmark_illust_restrict: 0 or 1. /\x2f bookmark_id and bookmark_illust_restrict are omitted if is_bookmarked is false. /\x2f /\x2f The new API is a dictionary: /\x2f /\x2f bookmarkData = { /\x2f bookmarkId: id, /\x2f private: false /\x2f } /\x2f /\x2f or null if not bookmarked. /\x2f /\x2f A couple sources of thumbnail data (bookmark_new_illust.php and search.php) /\x2f don't return the bookmark ID. We don't use this (we only edit bookmarks from /\x2f the image page, where we have full image data), so we omit bookmarkId from this /\x2f data. /\x2f /\x2f Some pages return buggy results. /ajax/user/id/profile/all includes bookmarkData, /\x2f but private is always false, so we can't tell if it's a private bookmark. This is /\x2f a site bug that we can't do anything about (it affects the site too). remappedMediaInfo.bookmarkData = null; if(!('is_bookmarked' in mediaInfo)) console.warn("Thumbnail info is missing key: is_bookmarked"); if(mediaInfo.is_bookmarked) { remappedMediaInfo.bookmarkData = { /\x2f See above. /\x2f bookmarkId: mediaInfo.bookmark_id, private: mediaInfo.bookmark_illust_restrict == 1, }; } /\x2f illustType can be a string in these instead of an int, so convert it. remappedMediaInfo.illustType = parseInt(remappedMediaInfo.illustType); if(source == "rankings") { /\x2f Rankings thumbnail info gives createDate as a Unix timestamp. Convert /\x2f it to the same format as everything else. let date = new Date(remappedMediaInfo.createDate*1000); remappedMediaInfo.createDate = date.toISOString(); } else if(source == "illust_list") { /\x2f This is the only source of thumbnail data that doesn't give createDate at /\x2f all. This source is very rarely used now, so just fill in a bogus date. remappedMediaInfo.createDate = new Date(0).toISOString(); } } else if(source == "internal") { remappedMediaInfo = mediaInfo; } else throw "Unrecognized source: " + source; /\x2f "internal" is for thumbnail data which is already processed. if(source != "internal") { /\x2f These fields are strings in some sources. Switch them to ints. for(let key of ["pageCount", "width", "height"]) { if(remappedMediaInfo[key] != null) remappedMediaInfo[key] = parseInt(remappedMediaInfo[key]); } /\x2f Different APIs return different thumbnail URLs. remappedMediaInfo.url = helpers.pixiv.getHighResThumbnailUrl(remappedMediaInfo.url); /\x2f Create a list of thumbnail URLs. remappedMediaInfo.previewUrls = []; for(let page = 0; page < remappedMediaInfo.pageCount; ++page) { let url = helpers.pixiv.getHighResThumbnailUrl(remappedMediaInfo.url, page); remappedMediaInfo.previewUrls.push(url); } /\x2f Rename .tags to .tagList, for consistency with the flat tag list in illust info. remappedMediaInfo.tagList = remappedMediaInfo.tags; delete remappedMediaInfo.tags; /\x2f Put id in illustId and set mediaId. This matches what we do in illust_data. remappedMediaInfo.illustId = remappedMediaInfo.id; remappedMediaInfo.mediaId = helpers.mediaId.fromIllustId(remappedMediaInfo.illustId); delete remappedMediaInfo.id; } /\x2f This is really annoying: the profile picture is the only field that's present in thumbnail /\x2f info but not illust info. We want a single basic data set for both, so that can't include /\x2f the profile picture. But, we do want to display it in places where we can't get user /\x2f info (muted search results), so store it separately. let profileImageUrl = null; if(remappedMediaInfo.profileImageUrl) { profileImageUrl = remappedMediaInfo.profileImageUrl; profileImageUrl = profileImageUrl.replace("_50.", "_170."), delete remappedMediaInfo.profileImageUrl; } return { remappedMediaInfo, profileImageUrl }; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/media-cache-mappings.js `), "/vview/misc/media-cache.js": loadBlob("application/javascript", `/\x2f This stores loaded info about images. /\x2f /\x2f Image info can be full or partial. Partial image info comes from Pixiv search APIs, /\x2f and only includes a subset of info. This is returned by a bunch of APIs that all /\x2f use different names for the same thing, so we remap them to a format consistent with /\x2f full image info. We also store image info for the local API here, which is always full /\x2f info. /\x2f /\x2f Full image info also includes manga page info and animation info. These require separate /\x2f API calls. We always load that data, since we almost always need it. /\x2f /\x2f This also includes extra image info, which is used for storing image edits. This is stored /\x2f in IDB for Pixiv images, and natively by the local API. /\x2f /\x2f Bookmark tags aren't handled here. It requires a separate API call to load and we don't /\x2f always need it, so it doesn't fit here. See ppixiv.extraCache.loadBookmarkDetails. /\x2f /\x2f Callers can request full or partial data. If partial data is requested, we can return /\x2f full data instead if we already have it, since it's a superset. If we have to load info /\x2f for a single image, we'll always load full info. We can only batch load partial info, /\x2f since Pixiv doesn't have any API to allow batch loading full info. /\x2f /\x2f Our media IDs encode Pixiv manga pages, but this only deals with top-level illustrations, and /\x2f the page number in illust media IDs is always 1 here. import LocalAPI from '/vview/misc/local-api.js'; import MediaCacheMappings from '/vview/misc/media-cache-mappings.js'; import MediaInfo, { MediaInfoEvents } from '/vview/misc/media-info.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f This handles fetching and caching image data. export default class MediaCache extends EventTarget { constructor() { super(); /\x2f Cached data: this._mediaInfo = { }; /\x2f Negative cache to remember illusts that don't exist, so we don't try to /\x2f load them repeatedly: this._nonexistantMediaIds = { }; /\x2f Promises for ongoing requests: this._mediaInfoLoadsFull = {}; this._mediaInfoLoadsPartial = {}; this.userProfileUrls = {}; ppixiv.settings.addEventListener("pixiv_cdn", () => this._updatePixivURLs()); /\x2f XXX: remove MediaInfoEvents.addEventListener("mediamodified", (e) => { let event = new Event("mediamodified"); event.mediaId = e.mediaId; this.dispatchEvent(event); }); }; /\x2f Load media data asynchronously. If full is true, return full info, otherwise return /\x2f partial info. /\x2f /\x2f If partial info is requested and we have full info, we'll reduce it to partial info if /\x2f safe is true, otherwise we'll just return full info. This helps avoid requesting /\x2f partial info and then accidentally using fields from full info. async getMediaInfo(mediaId, { full=true, safe=true }={}) { let mediaInfo = await this._getMediaInfoInner(mediaId, { full }); if(mediaInfo != null && !full && safe) mediaInfo = mediaInfo.partialInfo; return mediaInfo; } _getMediaInfoInner(mediaId, { full=true }={}) { mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); if(mediaId == null) return null; /\x2f Stop if we know this illust doesn't exist. if(mediaId in this._nonexistantMediaIds) return null; /\x2f If we already have the image data, just return it. if(this._mediaInfo[mediaId] != null && (!full || this._mediaInfo[mediaId].full)) return Promise.resolve(this._mediaInfo[mediaId]); /\x2f If there's already a load in progress, wait for the running promise. Note that this /\x2f promise will add to this._mediaInfo if it succeeds, but it won't necessarily return /\x2f the data directly since it may be a batch load. if(this._mediaInfoLoadsFull[mediaId] != null) return this._mediaInfoLoadsFull[mediaId].then(() => this._mediaInfo[mediaId]); if(!full && this._mediaInfoLoadsPartial[mediaId] != null) return this._mediaInfoLoadsPartial[mediaId].then(() => this._mediaInfo[mediaId]); /\x2f Start the load. If something's requesting partial info for a single image /\x2f then we'll almost always need full info too, so we always just load full info /\x2f here. let loadPromise = this._loadMediaInfo(mediaId); this._startedLoadingMediaInfoFull(mediaId, loadPromise); return loadPromise; } /\x2f Like getMediaInfo, but return the result immediately, or null if it's not /\x2f already loaded. getMediaInfoSync(mediaId, { full=true, safe=true }={}) { mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); let mediaInfo = this._mediaInfo[mediaId]; /\x2f If full info was requested and we only have partial info, don't return it. if(full && !mediaInfo?.full) return null; if(mediaInfo && !full && safe) mediaInfo = mediaInfo.partialInfo; return mediaInfo; } /\x2f If getMediaInfo returned null, return the error message. getMediaLoadError(mediaId) { mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); return this._nonexistantMediaIds[mediaId]; } /\x2f Refresh media info for the given media ID. /\x2f /\x2f If an image only has partial info loaded, this will cause its full info to be loaded. /\x2f /\x2f refreshFromDisk: If true, ask the server to reload from disk even if it thinks the file /\x2f hasn't changed. async refreshMediaInfo(mediaId, { refreshFromDisk=false }={}) { mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); await this._loadMediaInfo(mediaId, { refreshFromDisk }); } /\x2f Add full media info from a Pixiv API response. This will trigger loads for any /\x2f missing data, like manga info. addPixivFullMediaInfo(mediaInfo) { let mediaId = helpers.mediaId.fromIllustId(mediaInfo.id); let loadPromise = this._loadMediaInfo(mediaId, { mediaInfo }); this._startedLoadingMediaInfoFull(mediaId, loadPromise); return loadPromise; } _startedLoadingMediaInfoFull(mediaId, loadPromise) { /\x2f Remember that we're loading this ID, and unregister it when it completes. this._mediaInfoLoadsFull[mediaId] = loadPromise; this._mediaInfoLoadsFull[mediaId].finally(() => { if(this._mediaInfoLoadsFull[mediaId] === loadPromise) delete this._mediaInfoLoadsFull[mediaId]; }); } _startedLoadingMediaInfoPartial(mediaId, loadPromise) { /\x2f Remember that we're loading this ID, and unregister it when it completes. this._mediaInfoLoadsPartial[mediaId] = loadPromise; this._mediaInfoLoadsPartial[mediaId].finally(() => { if(this._mediaInfoLoadsPartial[mediaId] === loadPromise) delete this._mediaInfoLoadsPartial[mediaId]; }); } /\x2f Load mediaId and all data that it depends on. /\x2f /\x2f If we already have the image data (not necessarily the rest, like ugoira_metadata), /\x2f it can be supplied with mediaInfo. async _loadMediaInfo(mediaId, { mediaInfo=null, refreshFromDisk=false }={}) { mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); let [illustId] = helpers.mediaId.toIllustIdAndPage(mediaId); delete this._nonexistantMediaIds[mediaId]; /\x2f If this is a local image, use our API to retrieve it. if(helpers.mediaId.isLocal(mediaId)) return await this._loadLocalImageData(mediaId, { refreshFromDisk }); /\x2f console.log("Fetching", mediaId); let mangaPromise = null; let ugoiraPromise = null; /\x2f Given an illustType, start any fetches we can. let startLoading = (illustType, pageCount) => { /\x2f If we know the illust type and haven't started loading other data yet, start them. if(pageCount != null && pageCount > 1 && mangaPromise == null && mediaInfo?.mangaPages == null) mangaPromise = helpers.pixivRequest.get(\`/ajax/illust/\${illustId}/pages\`, {}); if(illustType == 2 && ugoiraPromise == null && (mediaInfo == null || mediaInfo.ugoiraMetadata == null)) ugoiraPromise = helpers.pixivRequest.get(\`/ajax/illust/\${illustId}/ugoira_meta\`); }; /\x2f If we already had partial info, we can start loading other metadata immediately instead /\x2f of waiting for the illust info to load, since we already know the image type. let partialInfo = this._mediaInfo[mediaId]; if(partialInfo != null) startLoading(partialInfo.illustType, partialInfo.pageCount); /\x2f If we don't have illust data, block while it loads. if(mediaInfo == null) { let illustResultPromise = helpers.pixivRequest.get(\`/ajax/illust/\${illustId}\`, {}); let illustResult = await illustResultPromise; if(illustResult == null || illustResult.error) { let message = illustResult?.message || "Error loading illustration"; console.log(\`Error loading illust \${illustId}; \${message}\`); this._nonexistantMediaIds[mediaId] = message; return null; } mediaInfo = illustResult.body; } ppixiv.tagTranslations.addTranslations(mediaInfo.tags.tags); /\x2f If we have extra data stored for this image, load it. let extraData = await ppixiv.extraImageData.loadAllPagesForIllust(illustId); mediaInfo.extraData = extraData; /\x2f Now that we have illust data, load anything we weren't able to load before. startLoading(mediaInfo.illustType, mediaInfo.pageCount); /\x2f Add an array of thumbnail URLs. mediaInfo.previewUrls = []; for(let page = 0; page < mediaInfo.pageCount; ++page) { let url = helpers.pixiv.getHighResThumbnailUrl(mediaInfo.urls.small, page); mediaInfo.previewUrls.push(url); } /\x2f Add a flattened tag list. mediaInfo.tagList = []; for(let tag of mediaInfo.tags.tags) mediaInfo.tagList.push(tag.tag); if(mangaPromise != null) { let mangaInfo = await mangaPromise; mediaInfo.mangaPages = mangaInfo.body; } if(ugoiraPromise != null) { let ugoiraResult = await ugoiraPromise; mediaInfo.ugoiraMetadata = ugoiraResult.body; } else mediaInfo.ugoiraMetadata = null; this._updateMediaInfoUrls(mediaInfo); /\x2f If this is a single-page image, create a dummy single-entry mangaPages array. This lets /\x2f us treat all images the same. if(mediaInfo.pageCount == 1) { mediaInfo.mangaPages = [{ width: mediaInfo.width, height: mediaInfo.height, /\x2f Rather than just referencing mediaInfo.urls, copy just the image keys that /\x2f exist in the regular mangaPages list (no thumbnails). urls: { original: mediaInfo.urls.original, regular: mediaInfo.urls.regular, small: mediaInfo.urls.small, } }]; } /\x2f Try to find the user's avatar URL. userIllusts contains a list of the user's illust IDs, /\x2f and only three have thumbnail data, probably for UI previews. For some reason these don't /\x2f always contain profileImageUrl, but usually one or two of the three do. Cache it if it's /\x2f there so it's ready for AvatarWidget if possible. if(mediaInfo.userIllusts) { for(let userIllustData of Object.values(mediaInfo.userIllusts)) { if(userIllustData?.profileImageUrl == null) continue; let { profileImageUrl } = MediaCacheMappings.remapPartialMediaInfo(userIllustData, "normal"); if(profileImageUrl) this.cacheProfilePictureUrl(mediaInfo.userId, profileImageUrl); } } /\x2f Remember that this is full info. mediaInfo.full = true; /\x2f The image data has both "id" and "illustId" containing the image ID. Remove id to /\x2f make sure we only use illustId, and set mediaId. This makes it clear what type of /\x2f ID you're getting. mediaInfo.mediaId = mediaId; delete mediaInfo.id; delete mediaInfo.userIllusts; ppixiv.guessImageUrl.addInfo(mediaInfo); return this.addFullMediaInfo(mediaInfo); } /\x2f Update URLs for all cached images after a change to the pixiv_cdn setting. _updatePixivURLs() { for(let mediaInfo of Object.values(this._mediaInfo)) this._updateMediaInfoUrls(mediaInfo); for(let mediaId of Object.keys(this._mediaInfo)) MediaInfo.callMediaInfoModifiedCallbacks(mediaId); } /\x2f Update URLs in mediaInfo that are affected by adjustImageUrlHostname. This can be called /\x2f again if the pixiv_cdn setting changes. _updateMediaInfoUrls(mediaInfo) { if(mediaInfo.urls) { for(let [key, url] of Object.entries(mediaInfo.urls)) { url = new URL(url); mediaInfo.urls[key] = helpers.pixiv.adjustImageUrlHostname(url).toString(); } } if(mediaInfo.previewUrls) { for(let page = 0; page < mediaInfo.previewUrls.length; ++page) { let url = mediaInfo.previewUrls[page]; mediaInfo.previewUrls[page] = helpers.pixiv.adjustImageUrlHostname(url).toString(); } } if(mediaInfo.mangaPages) { for(let page of mediaInfo.mangaPages) { for(let [key, url] of Object.entries(page.urls)) { url = helpers.pixiv.adjustImageUrlHostname(url); page.urls[key] = url.toString(); } } } if(mediaInfo.ugoiraMetadata) { /\x2f Switch the data URL to i-cf..pximg.net. let url = new URL(mediaInfo.ugoiraMetadata.originalSrc); url = helpers.pixiv.adjustImageUrlHostname(url); mediaInfo.ugoiraMetadata.originalSrc = url.toString(); } } /\x2f Load partial info for the given media IDs if they're not already loaded. /\x2f /\x2f If userId is set, mediaIds is known to be all posts from the same user. This /\x2f lets us use a better API. async batchGetMediaInfoPartial(mediaIds, { force=false, userId=null }={}) { let promises = []; let neededMediaIds = []; let localMediaIds = []; for(let mediaId of mediaIds) { mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); /\x2f If we're not forcing a refresh, skip this ID if it's already loaded. if(!force && this._mediaInfo[mediaId] != null) continue; /\x2f Ignore media IDs that have already failed to load. if(!force && this._nonexistantMediaIds[mediaId]) continue; /\x2f Skip IDs that are already loading. let existingLoad = this._mediaInfoLoadsFull[mediaId] ?? this._mediaInfoLoadsPartial[mediaId]; if(existingLoad) { promises.push(existingLoad); continue; } /\x2f Only load local IDs and illust IDs. let { type } = helpers.mediaId.parse(mediaId); if(helpers.mediaId.isLocal(mediaId)) localMediaIds.push(mediaId); else if(type == "illust") neededMediaIds.push(mediaId); } /\x2f If any of these are local IDs, load them with LocalAPI. if(localMediaIds.length) { let loadPromise = this._loadLocalMediaIds(localMediaIds); /\x2f Local API loads always give full info, so register these as full loads. for(let mediaId of mediaIds) this._startedLoadingMediaInfoFull(mediaId, loadPromise); promises.push(loadPromise); } if(neededMediaIds.length) { let loadPromise = this._doBatchGetMediaInfo(neededMediaIds, { userId }); for(let mediaId of mediaIds) this._startedLoadingMediaInfoPartial(mediaId, loadPromise); promises.push(loadPromise); } /\x2f Wait for all requests we started to finish, as well as any requests that /\x2f were already running. await Promise.all(promises); } /\x2f Run the low-level API call to load partial media info, and register the result. async _doBatchGetMediaInfo(mediaIds, { userId=null }={}) { if(mediaIds.length == 0) return; let illustIds = []; for(let mediaId of mediaIds) { if(helpers.mediaId.parse(mediaId).type != "illust") continue; let [illustId] = helpers.mediaId.toIllustIdAndPage(mediaId); illustIds.push(illustId); } /\x2f If all of these IDs are from the same user, we can use this API instead. It's /\x2f more useful since it includes bookmarking info, which is missing in /rpc/illust_list, /\x2f and it's in a much more consistent data format. Unfortunately, it doesn't work /\x2f with illusts from different users, which seems like an arbitrary restriction. /\x2f /\x2f (This actually doesn't restrict to the same user anymore. It's not clear if this /\x2f is a bug and you still have to specify an arbitrary user. There's no particular place /\x2f to take advantage of this right now, though.) if(userId != null) { let url = \`/ajax/user/\${userId}/profile/illusts\`; let result = await helpers.pixivRequest.get(url, { "ids[]": illustIds, work_category: "illustManga", is_first_page: "0", }); let illusts = Object.values(result.body.works); await this.addMediaInfosPartial(illusts, "normal"); } else { /\x2f This is a fallback if we're displaying search results we never received media /\x2f info for. It's a very old API and doesn't have all of the information newer ones /\x2f do: it's missing the AI flag, and only has a boolean value for "bookmarked" and no /\x2f bookmark data. However, it seems to be the only API available that can batch /\x2f load info for a list of unrelated illusts. let result = await helpers.pixivRequest.get("/rpc/illust_list.php", { illust_ids: illustIds.join(","), /\x2f Specifying this gives us 240x240 thumbs, which we want, rather than the 150x150 /\x2f ones we'll get if we don't (though changing the URL is easy enough too). page: "discover", /\x2f We do our own muting, but for some reason this flag is needed to get bookmark info. exclude_muted_illusts: 1, }); await this.addMediaInfosPartial(result, "illust_list"); } /\x2f Mark any media IDs that we asked for but didn't receive as not existing, so we won't /\x2f keep trying to load them. for(let mediaId of mediaIds) { if(this._mediaInfo[mediaId] == null && this._nonexistantMediaIds[mediaId] == null) this._nonexistantMediaIds[mediaId] = "Illustration doesn't exist"; } } /\x2f Cache partial media info that was loaded from a Pixiv search. This can come from /\x2f batchGetMediaInfoPartial() or from being included in a search result. /\x2f /\x2f Return the media IDs in the results, which can be returned as the media ID list from /\x2f data sources. addMediaInfosPartial = async (searchResult, source) => { if(searchResult.error) return []; /\x2f Ignore entries with "isAdContainer". searchResult = searchResult.filter(item => !item.isAdContainer); let allThumbInfo = []; let mediaIds = []; for(let thumbInfo of searchResult) { let { remappedMediaInfo, profileImageUrl } = MediaCacheMappings.remapPartialMediaInfo(thumbInfo, source); /\x2f Return media IDs for convenience. Return all media IDs, even if we skip updating /\x2f it below. mediaIds.push(remappedMediaInfo.mediaId); /\x2f The profile image URL isn't included in image info since it's not present in full /\x2f info. Store it separately. if(profileImageUrl) this.cacheProfilePictureUrl(remappedMediaInfo.userId, profileImageUrl); /\x2f If we already have full media info, don't replace it with partial info. This can happen /\x2f when a data source is refreshed. if(this.getMediaInfoSync(remappedMediaInfo.mediaId, { full: true }) != null) continue; allThumbInfo.push(remappedMediaInfo); } /\x2f Load any extra image data stored for these media IDs. These are stored per page, but /\x2f batch loaded per image. let illustIds = allThumbInfo.map((info) => info.illustId); let extraData = await ppixiv.extraImageData.batchLoadAllPagesForIllust(illustIds); for(let mediaInfo of allThumbInfo) { /\x2f Store extra data for each page. mediaInfo.extraData = extraData[mediaInfo.illustId] || {}; mediaInfo.full = false; this._updateMediaInfoUrls(mediaInfo); /\x2f Store the data. this.addFullMediaInfo(mediaInfo); } return mediaIds; } /\x2f Load image info from the local API. async _loadLocalImageData(mediaId, { refreshFromDisk }={}) { let mediaInfo = await LocalAPI.loadMediaInfo(mediaId, { refreshFromDisk }); if(!mediaInfo.success) { mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); this._nonexistantMediaIds[mediaId] = mediaInfo.reason; return null; } return this.addFullMediaInfo(mediaInfo.illust); } /\x2f Create or update a MediaInfo. mediaInfo is either a MediaInfo object, or a /\x2f complete media info result. /\x2f /\x2f Pixiv's raw API results don't return full info, so this shouldn't be called /\x2f directly for those. Use addPixivFullMediaInfo instead, which will make any /\x2f secondary loads we need. This can be called directly for local API results. addFullMediaInfo(mediaInfo) { /\x2f Create a MediaInfo wrapper. mediaInfo = MediaInfo.createFrom({ mediaInfo }); let { mediaId } = mediaInfo; mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); if(mediaId in this._mediaInfo) { /\x2f We already have a MediaInfo for this mediaId. Update the object we already /\x2f have instead of replacing it. this._mediaInfo[mediaId].updateInfo(mediaInfo); } else this._mediaInfo[mediaId] = mediaInfo; MediaInfo.callMediaInfoModifiedCallbacks(mediaId); return mediaInfo; } /\x2f Return true if all thumbs in mediaIds have been loaded, or are currently loading. /\x2f /\x2f We won't start fetching IDs that aren't loaded. areAllMediaIdsLoadedOrLoading(mediaIds) { for(let mediaId of mediaIds) { if(!this.isMediaIdLoadedOrLoading(mediaId)) return false; } return true; } isMediaIdLoadedOrLoading(mediaId) { mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); return this._mediaInfo[mediaId] != null || this._mediaInfoLoadsFull[mediaId] || this._mediaInfoLoadsPartial[mediaId]; } /\x2f Save data to extra_image_data, and update cached data. Returns the updated extra data. async saveExtraImageData(mediaId, edits) { let [illustId] = helpers.mediaId.toIllustIdAndPage(mediaId); /\x2f Load the current data from the database, in case our cache is out of date. let results = await ppixiv.extraImageData.loadMediaId([mediaId]); let data = results[mediaId] ?? { illust_id: illustId }; /\x2f Update each key, removing any keys which are null. for(let [key, value] of Object.entries(edits)) data[key] = value; /\x2f Delete any null keys. for(let [key, value] of Object.entries(data)) { if(value == null) delete data[key]; } /\x2f Update the edited timestamp. data.edited_at = Date.now() / 1000; /\x2f Save the new data. If the only fields left are illustId and edited_at, delete the record. if(Object.keys(data).length == 2) await ppixiv.extraImageData.deleteMediaId(mediaId); else await ppixiv.extraImageData.updateMediaId(mediaId, data); this.replaceExtraData(mediaId, data); return data; } /\x2f Refresh extraData in a loaded image. This does nothing if mediaId isn't loaded. replaceExtraData(mediaId, data) { let mediaInfo = this.getMediaInfoSync(mediaId, { full: false }); if(mediaInfo == null) return; mediaInfo.extraData[mediaId] = data; MediaInfo.callMediaInfoModifiedCallbacks(mediaId); } /\x2f Get the user's profile picture URL, or a fallback if we haven't seen it. getProfilePictureUrl(userId) { let result = this.userProfileUrls[userId]; if(!result) result = "https:/\x2fs.pximg.net/common/images/no_profile.png"; return result; } /\x2f Cache the URL to a user's avatar and preload it. cacheProfilePictureUrl(userId, url) { if(this.userProfileUrls[userId] == url) return; this.userProfileUrls[userId] = url; helpers.other.preloadImages([url]); } /\x2f Return the extra info for an image, given its image info. getExtraData(mediaInfo, mediaId, page=null) { if(mediaInfo == null) return { }; /\x2f If page is null, mediaId is already this page's ID. if(page != null) mediaId = helpers.mediaId.getMediaIdForPage(mediaId, page); return mediaInfo.extraData[mediaId] ?? {}; } /\x2f Get the width and height of mediaId from mediaInfo. /\x2f /\x2f This handles the inconsistency with page info: if we have partial image info, we only /\x2f know the dimensions for the first page. For page 1, we can always get the dimensions, /\x2f even from partial info. For other pages, we have to get the dimensions from mangaPages. /\x2f If we only have partial info, the other page dimensions aren't known and we'll return /\x2f null. getImageDimensions(mediaInfo, mediaId=null, page=null, { }={}) { if(mediaInfo == null) return { width: 1, height: 1 }; let pageInfo = mediaInfo; if(!helpers.mediaId.isLocal(mediaInfo.mediaId)) { if(page == null) { /\x2f For Pixiv images, at least one of mediaId or page must be specified so we /\x2f know what page we want. if(mediaId == null) throw new Error("At least one of mediaId or page must be specified"); page = helpers.mediaId.toIllustIdAndPage(mediaId)[1]; } if(page > 0) { /\x2f If this is partial info, we don't know the dimensions of pages past the first. /\x2f Use the size of the first page as a fallback. if(!mediaInfo.full) return { width: pageInfo.width, height: pageInfo.height }; pageInfo = mediaInfo.mangaPages[page]; } } return { width: pageInfo.width, height: pageInfo.height }; } async _loadLocalMediaIds(mediaIds) { if(mediaIds.length == 0) return; let result = await LocalAPI.localPostRequest(\`/api/illusts\`, { ids: mediaIds, }); if(!result.success) { console.error("Error reading IDs:", result.reason); return; } for(let illust of result.results) await this.addFullMediaInfo(illust); } /\x2f Run a search against the local API. async localSearch(path="", {...options}={}) { let result = await LocalAPI.localPostRequest(\`/api/list/\${path}\`, { ...options, }); if(!result.success) { console.error("Error reading directory:", result.reason); return result; } for(let illust of result.results) await this.addFullMediaInfo(illust); return result; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/media-cache.js `), "/vview/misc/media-ids.js": loadBlob("application/javascript", `import LocalAPI from '/vview/misc/local-api.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f Return the canonical URL for an illust. For most URLs this is /\x2f /artworks/12345. If manga is true, return the manga viewer page. export function getUrlForMediaId(mediaId, { manga=false}={}) { if(helpers.mediaId.isLocal(mediaId)) { /\x2f URLs for local files are handled differently. let args = helpers.args.location; LocalAPI.getArgsForId(mediaId, args); args.hash.set("view", "illust"); return args; } let [illustId, page] = helpers.mediaId.toIllustIdAndPage(mediaId); let args = new helpers.args("/", ppixiv.plocation); args.path = \`/artworks/\${illustId}\`; if(manga) args.hash.set("manga", "1"); if(page != null && page > 0) args.hash.set("page", page+1); return args; } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/media-ids.js `), "/vview/misc/media-info.js": loadBlob("application/javascript", `/\x2f MediaInfo holds data for a given illustration, thumbnail, folder, etc. /\x2f /\x2f This is mostly just a wrapper around the data we get back from the API to make what's /\x2f available from different sources more explicit. A MediaInfo is constructed with /\x2f MediaInfo.createFrom. import { helpers } from '/vview/misc/helpers.js'; export const MediaInfoEvents = new EventTarget(); let pendingMediaIdCallbacks = new Set(); export default class MediaInfo { /\x2f Send mediamodified when any data on a MediaInfo is modified. /\x2f /\x2f This is calso called when MediaCache has data for a new media ID. static callMediaInfoModifiedCallbacks(mediaId) { let wasEmpty = pendingMediaIdCallbacks.size == 0; pendingMediaIdCallbacks.add(mediaId); /\x2f Queue callMediaInfoModifiedCallbacksAsync if this is the first entry. if(wasEmpty) realSetTimeout(() => this.flushMediaInfoModifiedCallbacks(), 0); } /\x2f Call any waiting mediamodified callbacks. /\x2f /\x2f This is normally called automatically, but can be called to synchronously flush /\x2f the queue. static flushMediaInfoModifiedCallbacks() { let mediaIds = pendingMediaIdCallbacks; pendingMediaIdCallbacks = new Set(); for(let mediaId of mediaIds) { let event = new Event("mediamodified"); event.mediaId = mediaId; MediaInfoEvents.dispatchEvent(event); } } /\x2f If true, this is full media info, so full media fields can be accessed. get full() { return this._getInfo("full"); } /\x2f Getters for all fields. Most of these are given to us by the server, so we only /\x2f define setters for fields that we have a reason to update locally. We define all /\x2f getters explicitly, so it's obvious if we're trying to access a field that doesn't /\x2f exist for the data. /\x2f /\x2f Partial media info fields. These are included in all results, including bulk results /\x2f like searches, and are always available. get mediaId() { return this._getInfo("mediaId"); } get mediaType() { return helpers.mediaId.parse(this.mediaId).type; } /\x2f The regular Pixiv illustration ID. Most of the time we use our media ID /\x2f representation instead. get illustId() { return this._getInfo("illustId"); } /\x2f 0 or 1: illust, 2: animation (ugoira), "video": local video get illustType() { return this._getInfo("illustType"); } get illustTitle() { return this._getInfo("illustTitle"); } /\x2f Manga pages. This is 1 for videos and local images and any other sources that /\x2f don't have pages. get pageCount() { return this._getInfo("pageCount"); } get userId() { return this._getInfo("userId", null); } get userName() { return this._getInfo("userName"); } /\x2f mangaPages[0].width and mangaPages[0].height. This doesn't give the resolution for manga /\x2f pages, but is always available. get width() { return this._getInfo("width"); } get height() { return this._getInfo("height"); } /\x2f previewUrls is an array of thumbnail URLs. This is the same as mangaPages[*].urls.small. /\x2f Unlike mangaPages, this is always available, but doesn't have image dimensions. get previewUrls() { return this._getInfo("previewUrls"); } /\x2f If the post is bookmarked, this is Pixiv bookmark data. Otherwise, this is null. get bookmarkData() { return this._getInfo("bookmarkData"); } set bookmarkData(value) { this._setInfo("bookmarkData", value); } get createDate() { return this._getInfo("createDate"); } get tagList() { return this._getInfo("tagList"); } get aiType() { return this._getInfo("aiType", 0); } get likeCount() { return this._getInfo("likeCount"); } set likeCount(value) { this._setInfo("likeCount", value); } get bookmarkCount() { return this._getInfo("bookmarkCount"); } set bookmarkCount(value) { this._setInfo("bookmarkCount", value); } /\x2f Editor info. get extraData() { return this._getInfo("extraData"); } set extraData(value) { this._setInfo("extraData", value); } /\x2f Full media info fields. These are only available when we load full data. /\x2f /\x2f mangaPages is an array of page info: /\x2f [{ /\x2f width, height, /\x2f the width of the original image /\x2f urls: { /\x2f original, /\x2f the URL to the original, unresized image /\x2f small, /\x2f the high-resolution thumbnail for searches /\x2f } /\x2f ]} get mangaPages() { return this._getInfo("mangaPages"); } get illustComment() { return this._getInfo("illustComment", ""); } get ugoiraMetadata() { return this._getInfo("ugoiraMetadata"); } get seriesNavData() { return this._getInfo("seriesNavData"); } /\x2f Local images: get localPath() { return this._getInfo("localPath"); } /\x2f Get a key by name. /\x2f /\x2f If defaultValue is undefined, the key is expected to exist and an exception is thrown /\x2f if it doesn't. Otherwise, defaultValue is returned if the key isn't set. _getInfo(name, defaultValue=undefined) { if(!(name in this._info)) { if(defaultValue !== undefined) return defaultValue; throw new Error(\`Field \${name} not available in image info for \${this._info.mediaId}\`); } return this._info[name]; } /\x2f Update a value. We only expect to update fields in this way when they already exist. _setInfo(name, value) { if(!(name in this._info)) throw new Error(\`Field \${name} not available in image info for \${this._info.mediaId}\`); if(this._info[name] === value) return; this._info[name] = value; MediaInfo.callMediaInfoModifiedCallbacks(this.mediaId); } /\x2f If this is full media info, return a MediaInfo containing only partial media info. When /\x2f partial info is being requested we don't want to return full info. This makes it easier /\x2f to be sure that callers only requesting partial info don't accidentally access full fields /\x2f and having it seem to work because full info was cached. /\x2f /\x2f If this is already partial info, return ourself. /\x2f /\x2f For local API media info, return ourself. The local API is always full info. get partialInfo() { /\x2f This is implemented by the subclass if this media source supports it. return this; } /\x2f True if this is Vview media info. This is overridden by VviewMediaInfo. get isLocal() { return false; } /\x2f Create a MediaInfo from an API result with the appropriate subclass. static createFrom({mediaInfo}) { let classType; if(mediaInfo.classType) { /\x2f This object was created by serialize(). It's already been postprocessed, and /\x2f classType tells us which subclass to use. let classes = { VviewMediaInfo, PixivMediaInfo, }; classType = classes[mediaInfo.classType]; mediaInfo = mediaInfo.info; } else { /\x2f This is API data. Figure out the correct subclass from the media ID. if(helpers.mediaId.isLocal(mediaInfo.mediaId)) classType = VviewMediaInfo; else classType = PixivMediaInfo; /\x2f Run preprocessing if this subclass needs it. mediaInfo = classType.preprocessInfo({mediaInfo}); } return new classType({mediaInfo}); } /\x2f Get an object that can be serialized, and used to create a MediaInfo later with /\x2f createFrom. get serialize() { let classType; if(this.__proto__.constructor === VviewMediaInfo) classType = "VviewMediaInfo"; else classType = "PixivMediaInfo"; return { classType, info: this._info, }; } /\x2f The subclass can implement this to adjust mediaInfo when it's coming from the API /\x2f before it's sent to the constructor. This isn't called from serialized data, where /\x2f this has already been done. static preprocessInfo({mediaInfo}) { return mediaInfo; } /\x2f Use createFrom above instead of calling this directly. constructor({ mediaInfo }) { this._info = { ...mediaInfo }; } /\x2f Update this MediaInfo from data in another MediaInfo for the same mediaId. updateInfo(mediaInfo) { console.assert(mediaInfo instanceof MediaInfo, mediaInfo); for(let [key, value] of Object.entries(mediaInfo._info)) { /\x2f Allow full to change from false to true, so if we get full info after partial info /\x2f we'll upgrade. Don't allow it to change from true to false, so we can update full /\x2f info from partial info. if(key == "full" && !value) continue; /\x2f Make sure we never change mediaId. if(key == "mediaId") { console.assert(value == this._info.mediaId, \`Can't change media ID from \${this._info.mediaId} to \${value}\`); continue; } this._info[key] = value; } } /\x2f Return the main image to use for viewing the given image. /\x2f /\x2f If image_size_limit is set and the image is too large, use Pixiv's downscaled image instead. /\x2f This is an excessively low-res image with a max size of 1200, which seems like a resolution /\x2f that was picked a decade ago and never adjusted (1920 would make more sense), but it's the /\x2f only smaller image we have available. /\x2f /\x2f This is useful on mobile, where iOS's browser will OOM and silently reload the page if /\x2f we try to load extremely large images. This can also be enabled on desktop for users with /\x2f very limited bandwidth. For that use case it would make more sense to limit based on /\x2f file size, but that's not available. getMainImageUrl(page=0, { ignoreLimits=false }={}) { let mangaPage = this.mangaPages[page]; if(mangaPage == null) return { }; return { url: mangaPage.urls.original, width: mangaPage.width, height: mangaPage.height, }; } } /\x2f We have one MediaInfo for a post containing everything we know about it. Data /\x2f from Pixiv search results usually only has partial info. In many cases this is /\x2f all we need, so we only ask for full info when it's needed. However, we want to /\x2f be sure that if a caller is asking for partial info, it isn't accidentally using /\x2f info from full info, but appearing to work because full info was cached during /\x2f testing. /\x2f /\x2f PartialPixivMediaInfo wraps a PixivMediaInfo to check this, and throws an exception /\x2f if non-partial data is accessed. let partialPixivKeys = new Set([ "full", "mediaId", "bookmarkData", "createDate", "tagList", "extraData", "illustTitle", "illustType", "userName", "previewUrls", "illustId", "aiType", "userId", "pageCount", "width", "height", ]); function createPartialPixivMediaInfo(mediaInfo) { console.assert(mediaInfo instanceof PixivMediaInfo); return new Proxy(mediaInfo, { get(target, key, receiver) { /\x2f Awaiting an object tries to read "then". Don't log an error for this. if(key == "then") return undefined; /\x2f Always return false for mediaInfo.full, even if the underlying data is full. if(key == "full") return false; /\x2f Allow symbols and functions, so things like valueOf() work even though they're /\x2f not part of the data. /\x2f If this isn't a symbol or a function, onl if(key.constructor === Symbol || target[key] instanceof Function) return target[key]; if(!partialPixivKeys.has(key)) throw new Error(\`MediaInfo key \${key} isn't available in partial media info\`); return target[key]; }, has(target, key) { if(!partialPixivKeys.has(key)) throw new Error(\`MediaInfo key \${key} isn't available in partial media info\`); return key in target; }, set(obj, key, value) { if(!partialPixivKeys.has(key)) throw new Error(\`MediaInfo key \${key} can't be set in partial media info\`); obj[key] = value; return true; } }); } class PixivMediaInfo extends MediaInfo { get partialInfo() { return createPartialPixivMediaInfo(this); } getMainImageUrl(page=0, { ignoreLimits=false, forceLowRes=false }={}) { let mangaPage = this.mangaPages[page]; if(mangaPage == null) return { }; /\x2f Use the low-res image if image_size_limit is set and ignoreLimits is false. let pixels = mangaPage.width * mangaPage.height; let maxPixels = ppixiv.settings.get("image_size_limit"); if(!ignoreLimits && maxPixels != null && pixels > maxPixels) forceLowRes = true; if(forceLowRes) { /\x2f Use the downscaled image. This is currently always rescaled to fit a max /\x2f resolution of 1200. let ratio = Math.min(1, 1200 / mangaPage.width, 1200 / mangaPage.height); let width = Math.round(mangaPage.width * ratio); let height = Math.round(mangaPage.height * ratio); return { url: mangaPage.urls.regular, width, height }; } return super.getMainImageUrl(page); } } class VviewMediaInfo extends MediaInfo { /\x2f Vview media info is always full. get full() { return true; } /\x2f We always have mangaPages, and just implement width and height for compatibility. /\x2f This is null for folders. get width() { return this.mangaPages[0]?.width; } get height() { return this.mangaPages[0]?.height; } get pageCount() { return this.mediaType == "folder"? 0:1; } get isLocal() { return true; } /\x2f These aren't used locally, but don't warn about accesses to them. get ugoiraMetadata() { return null; } get seriesNavData() { return null; } /\x2f Preprocess API data to fit our data model. We don't do this in the constructor /\x2f since we don't want it to happen a second time when loading from serialized data. static preprocessInfo({mediaInfo}) { mediaInfo = { ...mediaInfo }; let { type } = helpers.mediaId.parse(mediaInfo.mediaId); if(type == "folder") { mediaInfo.mangaPages = []; /\x2f These metadata fields don't exist for folders. mediaInfo.userName = null; mediaInfo.illustType = 0; } else { /\x2f Vview images don't use pages and always have one page. mediaInfo.mangaPages = [{ width: mediaInfo.width, height: mediaInfo.height, urls: mediaInfo.urls, }]; } return mediaInfo; } /\x2f For local images, we can optionally use a high-quality GPU upscale for static /\x2f images. getMainImageUrl(page=0) { let result = this._getMainImageUrlWithUpscaling(page); return result ?? super.getMainImageUrl(page); } _getMainImageUrlWithUpscaling(page) { /\x2f This is only used for static images. if(this.illustType != 0) return null; let mangaPage = this.mangaPages[page]; if(mangaPage == null) return null; /\x2f The upscale setting can be: /\x2f null: no upscaling /\x2f 2x, 3x, 4x: upscale by the given factor. These are the upscales supported by the /\x2f underlying GPU resizer. /\x2f auto: upscale based on the image size. let upscaleSetting = ppixiv.settings.get("upscaling"); if(!upscaleSetting) return null; /\x2f The upscaler will do 2x, 3x and 4x, but in practice going beyond 2x is nearly /\x2f indistinguishable from 2x with regular upscaling. It's already done what it can /\x2f with the image. Just decide whether to use 2x upscaling or none at all, so we /\x2f don't waste time upscaling images that aren't low-res. /\x2f /\x2f For now we just pick an arbitrary max resolution to turn on upscaling. let { width, height } = mangaPage; let maxSizeForUpscaling = 2000; if(width >= maxSizeForUpscaling && height > maxSizeForUpscaling) return null; return { url: mangaPage.urls.upscale2x, /\x2f We currently don't scale these values to reflect the upscale. The viewer /\x2f only uses them for the aspect ratio (which won't change) unless it's in /\x2f "actual size" mode, so scaling these causes the image size to jump, but only /\x2f when the zoom is at actual size. It's better to just leave it at the /\x2f natural size, so the zoom stays put. width: mangaPage.width, height: mangaPage.height, }; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/media-info.js `), "/vview/misc/muting.js": loadBlob("application/javascript", `/\x2f This handles querying whether a tag or a user is muted. /\x2f /\x2f The "mutes-changed" event is fired here when any mute list is modified. import { helpers } from '/vview/misc/helpers.js'; export default class Muting extends EventTarget { constructor() { super(); this._mutedTags = []; this._mutedUserIds = []; /\x2f This is used to tell other tabs when mutes change, so adding mutes takes effect without /\x2f needing to reload all other tabs. this._syncMutesChannel = new BroadcastChannel("ppixiv:mutes-changed"); this._syncMutesChannel.addEventListener("message", this._receivedMessage); } get pixivMutedTags() { return this._mutedTags; } get pixivMutedUserIds() { return this._mutedUserIds; } /\x2f Set the list of tags and users muted via Pixiv's settings. setMutes({pixivMutedTags, pixivMutedUserIds}={}) { if(pixivMutedTags == null && pixivMutedUserIds == null) return; if(pixivMutedTags != null) this._mutedTags = pixivMutedTags; if(pixivMutedUserIds != null) this._mutedUserIds = pixivMutedUserIds; this._storeMutes(); this._fireMutesChanged(); } /\x2f Extra mutes have a similar format to the /ajax/mute/items API: /\x2f /\x2f [{ /\x2f "type": "tag", /\x2f or user /\x2f "value": "tag or user ID", /\x2f "label": "tag or username" /\x2f ]} get extraMutes() { return ppixiv.settings.get("extraMutes"); } set extraMutes(mutedUsers) { ppixiv.settings.set("extraMutes", mutedUsers); this._fireMutesChanged(); } /\x2f Shortcut to get just extra muted tags: get _extraMutedTags() { let tags = []; for(let mute of this.extraMutes) if(mute.type == "tag") tags.push(mute.value); return tags; } /\x2f Fire mutes-changed to let UI know that a mute list has changed. _fireMutesChanged() { /\x2f If either of these are null, we're still being initialized. Don't fire events yet. if(this.pixivMutedTags == null || this.pixivMutedUserIds == null) return; this.dispatchEvent(new Event("mutes-changed")); /\x2f Tell other tabs that mutes have changed. this._broadcastMutes(); } _broadcastMutes() { /\x2f Don't do this if we're inside _broadcastMutes because another tab sent this to us. if(this._handlingBroadcastMutes) return; this._syncMutesChannel.postMessage({ pixivMutedTags: this.pixivMutedTags, pixivMutedUserIds: this.pixivMutedUserIds, }); } _receivedMessage = (e) => { let data = e.data; if(this._handlingBroadcastMutes) { console.error("recursive"); return; } /\x2f Don't fire the event if nothing is actually changing. This happens a lot when new tabs /\x2f are opened and they broadcast current mutes. if(JSON.stringify(this.pixivMutedTags) == JSON.stringify(data.pixivMutedTags) && JSON.stringify(this.pixivMutedUserIds) == JSON.stringify(data.pixivMutedUserIds)) return; this._handlingBroadcastMutes = true; try { this.setMutes({pixivMutedTags: data.pixivMutedTags, pixivMutedUserIds: data.pixivMutedUserIds}); } finally { this._handlingBroadcastMutes = false; } }; isUserIdMuted(userId) { if(this._mutedUserIds.indexOf(userId) != -1) return true; for(let {value: mutedUserId} of this.extraMutes) { if(userId == mutedUserId) return true; } return false; }; /\x2f Unmute userId. /\x2f /\x2f This checks both Pixiv's unmute list and our own, so it can always be used if /\x2f isUserIdMuted is true. async unmuteUserId(userId) { this.removeExtraMute(userId, {type: "user"}); if(this._mutedUserIds.indexOf(userId) != -1) await this.removePixivMute(userId, {type: "user"}); } /\x2f Return true if any tag in tagList is muted. anyTagMuted(tagList) { let _extraMutedTags = this._extraMutedTags; for(let tag of tagList) { if(tag.tag) tag = tag.tag; if(this._mutedTags.indexOf(tag) != -1 || _extraMutedTags.indexOf(tag) != -1) return tag; } return null; } /\x2f Return true if the user is able to add to the Pixiv mute list. get _canAddPixivMutes() { /\x2f Non-premium users can only have one mute, and that's shared across both tags and users. let total_mutes = this.pixivMutedTags.length + this.pixivMutedUserIds.length; return ppixiv.pixivInfo.premium || total_mutes == 0; } /\x2f Pixiv doesn't include mutes in the initialization data for pages on mobile. We load /\x2f it with an API call, but we don't want to wait for that to return and delay every page /\x2f load. However, we also don't want to not have mute info and possibly show muted images /\x2f briefly on startup. Work around this by caching mutes to storage, and using the cached /\x2f mutes while we're waiting to receive them. _storeMutes() { /\x2f This is only needed for mobile. if(!ppixiv.mobile) return; ppixiv.settings.set("cached_mutes", { tags: this._mutedTags, userIds: this._mutedUserIds, }); } /\x2f Load mutes cached by _storeMutes. This is only used until we load the mute list, and /\x2f is only used on mobile. loadCachedMutes() { /\x2f This is only needed for mobile. if(!ppixiv.mobile) return; let cachedMutes = ppixiv.settings.get("cached_mutes"); if(cachedMutes == null) { console.log("No cached mutes to load"); return; } let { tags, userIds } = cachedMutes; this._mutedTags = tags; this._mutedUserIds = userIds; } /\x2f Request the user's mute list. This is only used on mobile. async fetchMutes() { /\x2f Load the real mute list. let data = await helpers.pixivRequest.get(\`/touch/ajax/user/self/status?lang=en\`); if(data.error) { console.log("Error loading user info:", data.message); return; } let mutes = data.body.user_status.mutes; let pixivMutedTags = []; for(let [tag, info] of Object.entries(mutes.tags)) { /\x2f "enabled" seems to always be true. if(info.enabled) pixivMutedTags.push(tag); } let pixivMutedUserIds = []; for(let [userId, info] of Object.entries(mutes.users)) { if(info.enabled) pixivMutedUserIds.push(userId); } this.setMutes({pixivMutedTags, pixivMutedUserIds}); } /\x2f If the user has premium, add to Pixiv mutes. Otherwise, add to extra mutes. async addMute(value, label, {type}) { if(ppixiv.pixivInfo.premium) { await this.addPixivMute(value, {type: type}); } else { if(type == "user" && label == null) { /\x2f We need to know the user's username to add to our local mute list. let user_data = await ppixiv.userCache.getUserInfo(value); label = user_data.name; } await this.addExtraMute(value, label, {type: type}); } } /\x2f Mute a user or tag using the Pixiv mute list. type must be "tag" or "user". async addPixivMute(value, {type}) { console.log(\`Adding \${value} to the Pixiv \${type} mute list\`); if(!this._canAddPixivMutes) { ppixiv.message.show("The Pixiv mute list is full."); return; } /\x2f Stop if the value is already in the list. let muteList = type == "tag"? "pixivMutedTags":"pixivMutedUserIds"; let mutes = this[muteList]; if(mutes.indexOf(value) != -1) return; /\x2f Get the label. If this is a tag, the label is the same as the tag, otherwise /\x2f get the user's username. We only need this for the message we'll display at the /\x2f end. let label = value; if(type == "user") label = (await ppixiv.userCache.getUserInfo(value)).name; /\x2f Note that this doesn't return an error if the mute list is full. It returns success /\x2f and silently does nothing. let result = await helpers.pixivRequest.rpcPost("/ajax/mute/items/add", { context: "illust", type: type, value: value, }); if(result.error) { ppixiv.message.show(result.message); return; } /\x2f The API call doesn't return the updated list, so we have to update it manually. mutes.push(value); /\x2f Pixiv sorts the muted tag list, so mute it here to match. if(type == "tag") mutes.sort(); let update = { }; update[muteList] = mutes; this.setMutes(update); ppixiv.message.show(\`Muted the \${type} \${label}\`); } /\x2f Remove item from the Pixiv mute list. type must be "tag" or "user". async removePixivMute(value, {type}) { console.log(\`Removing \${value} from the Pixiv muted \${type} list\`); /\x2f Get the label. If this is a tag, the label is the same as the tag, otherwise /\x2f get the user's username. We only need this for the message we'll display at the /\x2f end. let label = value; if(type == "user") label = (await ppixiv.userCache.getUserInfo(value)).name; let result = await helpers.pixivRequest.rpcPost("/ajax/mute/items/delete", { context: "illust", type: type, value: value, }); if(result.error) { ppixiv.message.show(result.message); return; } /\x2f The API call doesn't return the updated list, so we have to update it manually. let muteList = type == "tag"? "pixivMutedTags":"pixivMutedUserIds"; let mutes = this[muteList]; let idx = mutes.indexOf(value); if(idx != -1) mutes.splice(idx, 1); let update = { }; update[muteList] = mutes; this.setMutes(update); ppixiv.message.show(\`Unmuted the \${type} \${label}\`); } /\x2f value is a tag name or user ID. label is the tag or username. type must be /\x2f "tag" or "user". async addExtraMute(value, label, {type}) { console.log(\`Adding \${value} (\${label}) to the extra muted \${type} list\`); /\x2f Stop if the item is already in the list. let mutes = this.extraMutes; for(let {value: mutedValue, type: mutedType} of mutes) if(value == mutedValue && type == mutedType) { console.log("Item is already muted"); return; } mutes.push({ type: type, value: value, label: label, }); mutes.sort((lhs, rhs) => { return lhs.label.localeCompare(rhs.label); }); this.extraMutes = mutes; ppixiv.message.show(\`Muted the \${type} \${label}\`); } async removeExtraMute(value, {type}) { console.log(\`Removing \${value} from the extra muted \${type} list\`); let mutes = this.extraMutes; for(let idx = 0; idx < mutes.length; ++idx) { let mute = mutes[idx]; if(mute.type == type && mute.value == value) { ppixiv.message.show(\`Unmuted the \${mute.type} \${mute.label}\`); mutes.splice(idx, 1); break; } } this.extraMutes = mutes; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/muting.js `), "/vview/misc/pixiv-ugoira-downloader.js": loadBlob("application/javascript", `/\x2f Encode a Pixiv video to MJPEG, using an MKV container. /\x2f /\x2f Other than having to wrangle the MKV format, this is easy: the source files appear to always /\x2f be JPEGs, so we don't need to do any conversions and the encoding is completely lossless (other /\x2f than the loss Pixiv forces by reencoding everything to JPEG). The result is standard and plays /\x2f in eg. VLC, but it's not a WebM file and browsers don't support it. These can also be played /\x2f when reading from the local API, since it'll decode these videos and turn them back into a ZIP. import EncodeMKV from '/vview/misc/encode-mkv.js'; import ZipImageDownloader from '/vview/misc/zip-image-downloader.js'; import { helpers } from '/vview/misc/helpers.js'; export default class PixivUgoiraDownloader { constructor(illustData, progress) { this.illustData = illustData; this.onprogress = progress; this.metadata = illustData.ugoiraMetadata; this.mimeType = illustData.ugoiraMetadata.mimeType; this.frames = []; this.loadAllFrames(); } async loadAllFrames() { ppixiv.message.show(\`Downloading video...\`); let downloader = new ZipImageDownloader(this.metadata.originalSrc, { onprogress: (progress) => { if(!this.onprogress) return; try { this.onprogress.set(progress); } catch(e) { console.error(e); } }, }); while(1) { let file = await downloader.getNextFrame(); if(file == null) break; this.frames.push(file); } ppixiv.message.hide(); /\x2f Some posts have the wrong dimensions in illustData (63162632). If we use it, the resulting /\x2f file won't play. Decode the first image to find the real resolution. let img = document.createElement("img"); let blob = new Blob([this.frames[0]], {type: this.mimeType || "image/png"}); let firstFrameURL = URL.createObjectURL(blob); img.src = firstFrameURL; await helpers.other.waitForImageLoad(img); URL.revokeObjectURL(firstFrameURL); let width = img.naturalWidth; let height = img.naturalHeight; try { let encoder = new EncodeMKV(width, height); /\x2f Add each frame to the encoder. let frameCount = this.illustData.ugoiraMetadata.frames.length; for(let frame = 0; frame < frameCount; ++frame) { let frameData = this.frames[frame]; let duration = this.metadata.frames[frame].delay; encoder.add(frameData, duration); }; /\x2f There's no way to encode the duration of the final frame of an MKV, which means the last frame /\x2f will be effectively lost when looping. In theory the duration field on the file should tell the /\x2f player this, but at least VLC doesn't do that. /\x2f /\x2f Work around this by repeating the last frame with a zero duration. /\x2f /\x2f In theory we could set the "invisible" bit on this frame ("decoded but not displayed"), but that /\x2f doesn't seem to be used, at least not by VLC. let frameData = this.frames[frameCount-1]; encoder.add(frameData, 0); /\x2f Build the file. let mkv = encoder.build(); let filename = this.illustData.userName + " - " + this.illustData.illustId + " - " + this.illustData.illustTitle + ".mkv"; helpers.saveBlob(mkv, filename); } catch(e) { console.error(e); }; /\x2f Completed: if(this.onprogress) this.onprogress.set(null); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/pixiv-ugoira-downloader.js `), "/vview/misc/polyfills.js": loadBlob("application/javascript", `export default function installPolyfills() { /\x2f Make IDBRequest an async generator. /\x2f /\x2f Note that this will clobber onsuccess and onerror on the IDBRequest. if(!IDBRequest.prototype[Symbol.asyncIterator]) { /\x2f This is awful (is there no syntax sugar to make this more readable?), but it /\x2f makes IDBRequests much more sane to use. IDBRequest.prototype[Symbol.asyncIterator] = function() { return { next: () => { return new Promise((accept, reject) => { this.onsuccess = (e) => { let entry = e.target.result; if(entry == null) { accept({ done: true }); return; } accept({ value: entry, done: false }); entry.continue(); } this.onerror = (e) => { reject(e); }; }); } }; }; } /\x2f Add commitStylesIfPossible to Animation. /\x2f /\x2f Animation.commitStyles throws an exception in some cases. This is almost never useful and /\x2f it's a pain to have to wrap every call in an exception handler, so this converts it to a /\x2f return value. Animation.prototype.commitStylesIfPossible = function() { try { this.commitStyles(); return true; } catch(e) { console.error(e); return false; } } /\x2f Firefox still doesn't support inert. We simulate it with a pointer-events: none style, so /\x2f implement the attribute. if(!("inert" in document.documentElement)) { Object.defineProperty(HTMLElement.prototype, "inert", { get: function() { return this.hasAttribute("inert"); }, set: function(value) { if(value) this.setAttribute("inert", "inert"); else this.removeAttribute("inert", "inert"); }, }); } /\x2f Work around a strange iOS Safari bug: we don't always get dblclick events unless /\x2f at least one dblclick listener exists on the document. if(ppixiv.ios) document.addEventListener("dblclick", (e) => { }); /\x2f Old Firefox: if(!("throwIfAborted" in AbortSignal.prototype)) { AbortSignal.prototype.throwIfAborted = function() { if(this.aborted) throw new DOMException(this.reason ?? "Signal was aborted"); } } }; /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/polyfills.js `), "/vview/misc/recent-bookmark-tags.js": loadBlob("application/javascript", `/\x2f Keep track of bookmark tags the user has used recently. export default class RecentBookmarkTags { static setRecentBookmarkTags(tags) { ppixiv.settings.set("recent-bookmark-tags", JSON.stringify(tags)); } static getRecentBookmarkTags() { let recentBookmarkTags = ppixiv.settings.get("recent-bookmark-tags"); if(recentBookmarkTags == null) return []; return JSON.parse(recentBookmarkTags); } /\x2f Move tagList to the beginning of the recent tag list, and prune tags at the end. static updateRecentBookmarkTags(tagList) { /\x2f Move the tags we're using to the top of the recent bookmark tag list. let recentBookmarkTags = this.getRecentBookmarkTags(); for(let i = 0; i < tagList.length; ++i) { let idx = recentBookmarkTags.indexOf(tagList[i]); if(idx != -1) recentBookmarkTags.splice(idx, 1); } for(let i = 0; i < tagList.length; ++i) recentBookmarkTags.unshift(tagList[i]); /\x2f Remove tags that haven't been used in a long time. recentBookmarkTags.splice(100); this.setRecentBookmarkTags(recentBookmarkTags); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/recent-bookmark-tags.js `), "/vview/misc/saved-search-tags.js": loadBlob("application/javascript", `/\x2f This handles the list of saved and recent search tags. For backwards-compatibility /\x2f this is stored in the "recent-tag-searches" setting. This has the format: /\x2f /\x2f [ /\x2f "tag1", /\x2f "tag2", /\x2f { "type": "section", "name: "Saved Tags" }, /\x2f "tag3", /\x2f { "type": "section", "name: "Saved Tags 2" }, /\x2f "tag3", /\x2f ] /\x2f /\x2f Tags are simple strings. All tags before the first section are recent tags, and all /\x2f saved tags are always in a section. The order of tags and groups can be edited by the /\x2f user. /\x2f /\x2f Putting recent tags first allows the older simple tag list format to have the same /\x2f meaning, so no migrations are needed. export default class SavedSearchTags { static data() { return ppixiv.settings.get("recent-tag-searches") || [];; } /\x2f Return a map of all recent and saved tags, mapping from group names to lists /\x2f of searches. Recent searches are returned with a tag of "null". The map is /\x2f ordered. static getAllGroups({data=null}={}) { let result = new Map(); result.set(null, []); /\x2f recents data ??= this.data(); let inGroup = null; for(let recentTag of data) { if((recentTag instanceof Object) && recentTag.type == "section") { inGroup = recentTag.name; result.set(inGroup, []); continue; } result.get(inGroup).push(recentTag); } return result; } /\x2f Set recent-tag-searches from a group map returned by getAllGroups. static setAllGroups(groups) { let data = []; for(let [name, tagsInGroup] of groups.entries()) { if(name != null) { data.push({ type: "section", name, }); } for(let tag of tagsInGroup) data.push(tag); } ppixiv.settings.set("recent-tag-searches", data); window.dispatchEvent(new Event("recent-tag-searches-changed")); } /\x2f Return all individual tags that the user has in recents and saved searches. static getAllUsedTags() { let allTags = new Set(); for(let groupTags of this.getAllGroups().values()) { for(let tags of groupTags) for(let tag of tags.split(" ")) allTags.add(tag); } return allTags; } /\x2f Add tag to the recent search list, or move it to the front. If group is set, add /\x2f a saved search in the given group. If group is null, add to the recent list. /\x2f /\x2f If tag is null, just create group if it doesn't exist. static add(tag, { group=null, addToEnd=true }={}) { if(this._disableAddingSearchTags || tag == "") return; let recentTags = ppixiv.settings.get("recent-tag-searches") || []; /\x2f If tag is already in the list as a recent tag, remove it. if(tag != null) { /\x2f If tag is a saved tag, don't change it. if(this.groupNameForTag(tag) != null) return; let idx = recentTags.indexOf(tag); if(idx != -1) recentTags.splice(idx, 1); } /\x2f If we're adding it as a recent, add it to the beginning. If we're adding it as /\x2f a saved tag, create the null separating recents and saved tags if needed, and add /\x2f the tag at the end. if(group == null) recentTags.unshift(tag); else { /\x2f Find or create the group header for this group. let [startIdx, endIdx] = this._findGroupRange(group); if(startIdx == -1) { console.log(\`Created tag group: \${group}\`); recentTags.push({ type: "section", name: group, }); startIdx = endIdx = recentTags.length; } /\x2f If tag is null, we're just creating the group and not adding anything to it. if(tag != null) { if(addToEnd) recentTags.splice(endIdx, 0, tag); else recentTags.splice(startIdx+1, 0, tag); } } ppixiv.settings.set("recent-tag-searches", recentTags); window.dispatchEvent(new Event("recent-tag-searches-changed")); } /\x2f Replace a saved tag. static modifyTag(oldTags, newTags) { if(oldTags == newTags) return; let data = this.data(); if(this.findIndex({tag: newTags, data}) != -1) { ppixiv.message.show(\`Saved tag already exists\`); return; } /\x2f Find the tag. let idx = this.findIndex({tag: oldTags, data}); if(idx == -1) return; data[idx] = newTags; ppixiv.settings.set("recent-tag-searches", data); window.dispatchEvent(new Event("recent-tag-searches-changed")); ppixiv.message.show(\`Saved tag updated\`); } /\x2f Return [start,end) in the tag list for the given section, where start is the /\x2f index of the section header and end is one past last entry in the group. If /\x2f the section doesn't exist, return [-1,-1]. static _findGroupRange(sectionName, { data }={}) { let recentTags = data ?? this.data(); /\x2f Find the start of the group. recent searches always start at the beginning. let startIdx = -1; if(sectionName == null) startIdx = 0; else { for(let idx = 0; idx < recentTags.length; ++idx) { let group = recentTags[idx]; if(!(group instanceof Object) || group.type != "section") continue; if(group.name != sectionName) continue; startIdx = idx; break; } } /\x2f Return -1 if the group doesn't exist. if(startIdx == -1) return [-1, -1]; /\x2f Find the end of the group. for(let idx = startIdx+1; idx < recentTags.length; ++idx) { let group = recentTags[idx]; if(!(group instanceof Object) || group.type != "section") continue; return [startIdx, idx]; } return [startIdx,recentTags.length]; } /\x2f Delete the given group and all tags inside it. static deleteGroup(group) { let [startIdx, endIdx] = this._findGroupRange(group); if(startIdx == -1) return; let count = endIdx - startIdx; let recentTags = ppixiv.settings.get("recent-tag-searches") || []; recentTags.splice(startIdx, count); ppixiv.settings.set("recent-tag-searches", recentTags); window.dispatchEvent(new Event("recent-tag-searches-changed")); ppixiv.message.show(\`Group "\${group}" deleted\`); } /\x2f Rename a group. The new name must not already exist. static renameGroup(from, to) { let fromIdx = this.findIndex({group: from}); if(fromIdx == -1) return; if(this.findIndex({group: to}) != -1) { ppixiv.message.show(\`Group "\${to}" already exists\`); return; } let recentTags = ppixiv.settings.get("recent-tag-searches") || []; recentTags[fromIdx].name = to; ppixiv.settings.set("recent-tag-searches", recentTags); /\x2f If this group was collapsed, rename it in collapsed-tag-groups. let collapsedGroups = this.getCollapsedTagGroups(); if(collapsedGroups.has(from)) { collapsedGroups.delete(from); collapsedGroups.add(to); ppixiv.settings.set("collapsed-tag-groups", [...collapsedGroups]); } window.dispatchEvent(new Event("recent-tag-searches-changed")); } static moveGroup(group, { down }) { let data = ppixiv.settings.get("recent-tag-searches") || []; let groups = this.getAllGroups(data); let tagGroups = Array.from(groups.keys()); let idx = tagGroups.indexOf(group); if(idx == -1) return; /\x2f Reorder tagGroups. let swapWith = idx + (down? +1:-1); if(swapWith < 0 || swapWith >= tagGroups.length) return; /\x2f Refuse to move recents, which must always be the first group. if(tagGroups[idx] == null || tagGroups[swapWith] == null) return; [tagGroups[idx], tagGroups[swapWith]] = [tagGroups[swapWith], tagGroups[idx]]; let newGroups = new Map(); for(let group of tagGroups) { newGroups.set(group, groups.get(group)); } this.setAllGroups(newGroups); } static getCollapsedTagGroups() { return new Set(ppixiv.settings.get("collapsed-tag-groups") || []); } /\x2f groupName can be null to collapse recents. If collapse is "toggle", toggle the current value. static setTagGroupCollapsed(groupName, collapse) { let collapsedGroups = this.getCollapsedTagGroups(); if(collapse == "toggle") collapse = !collapsedGroups.has(groupName); if(collapsedGroups.has(groupName) == collapse) return; if(collapse) collapsedGroups.add(groupName); else collapsedGroups.delete(groupName); ppixiv.settings.set("collapsed-tag-groups", [...collapsedGroups]); window.dispatchEvent(new Event("recent-tag-searches-changed")); } /\x2f This is a hack used by TagSearchBoxWidget to temporarily disable adding to history. static disableAddingSearchTags(value) { this._disableAddingSearchTags = value; } /\x2f recent-tag-searches contains both recent tags and saved tags. Recent tags are listed /\x2f first for compatibility, followed by a group labels, followed by saved tags. A group /\x2f label looks like: { "type": "section", "name": "section name" }. Section names are /\x2f always unique. /\x2f /\x2f Return the group name if tag is a saved tag, otherwise null. static groupNameForTag(tag) { let recentTags = ppixiv.settings.get("recent-tag-searches") || []; let inGroup = null; for(let recentTag of recentTags) { if((recentTag instanceof Object) && recentTag.type == "section") { inGroup = recentTag.name; continue; } if(recentTag == tag) return inGroup; } return null; } static remove(tag) { /\x2f Remove tag from the list. There should normally only be one. let recentTags = ppixiv.settings.get("recent-tag-searches") || []; let idx = recentTags.indexOf(tag); if(idx == -1) return; recentTags.splice(idx, 1); ppixiv.settings.set("recent-tag-searches", recentTags); window.dispatchEvent(new Event("recent-tag-searches-changed")); } /\x2f Move tag in the list so its index is to_idx. static move(tag, to_idx) { /\x2f Remove tag from the list. There should normally only be one. let recentTags = ppixiv.settings.get("recent-tag-searches") || []; let idx = recentTags.indexOf(tag); if(idx == -1) return; if(idx == to_idx) return; /\x2f If the target index is after its current position, subtract one to adjust for /\x2f the offset changing as we remove the old one. if(to_idx > idx) to_idx--; recentTags.splice(idx, 1); recentTags.splice(to_idx, 0, tag); ppixiv.settings.set("recent-tag-searches", recentTags); window.dispatchEvent(new Event("recent-tag-searches-changed")); } /\x2f Return the index in recent-tag-searches of the given tag or group, or -1 if it /\x2f doesn't exist. static findIndex({tag, group, data=null}) { data ??= ppixiv.settings.get("recent-tag-searches") || []; for(let idx = 0; idx < data.length; ++idx) { let recentTag = data[idx]; if((recentTag instanceof Object) && recentTag.type == "section") { if(group != null && recentTag.name == group) return idx; } else { if(tag != null && recentTag == tag) return idx; } } return -1; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/saved-search-tags.js `), "/vview/misc/send-image.js": loadBlob("application/javascript", `/\x2f This handles sending images from one tab to another. import Widget from '/vview/widgets/widget.js'; import DialogWidget from '/vview/widgets/dialog.js'; import MediaInfo from '/vview/misc/media-info.js'; import { LocalBroadcastChannel } from '/vview/misc/local-api.js'; import { Timer } from '/vview/misc/helpers.js'; import { helpers } from '/vview/misc/helpers.js'; export default class SendImage { constructor() { /\x2f This is a singleton, so we never close this channel. this._sendImageChannel = new LocalBroadcastChannel("ppixiv:send-image"); /\x2f A UUID we use to identify ourself to other tabs: this.tabId = this._createTabId(); this._tabIdTiebreaker = Date.now() this._pendingMovement = [0, 0]; window.addEventListener("unload", this._windowUnload); /\x2f If we gain focus while quick view is active, finalize the image. Virtual /\x2f history isn't meant to be left enabled, since it doesn't interact with browser /\x2f history. On mobile, do this on any touch. window.addEventListener(ppixiv.mobile? "pointerdown":"focus", (e) => { this._finalizeQuickViewImage(); }, { capture: true }); ppixiv.mediaCache.addEventListener("mediamodified", ({mediaId}) => { this._broadcastMediaChanges(mediaId); }); this._sendImageChannel.addEventListener("message", this.receivedMessage); this._broadcastTabInfo(); /\x2f Ask other tabs to broadcast themselves, so we can see if we have a conflicting /\x2f tab ID. this.sendMessage({ message: "list-tabs" }); } /\x2f Return true if this feature should be displayed. /\x2f /\x2f On desktop this can be used across tabs, and when native this can be used /\x2f across clients. It isn't useful when on mobile and on Pixiv, since there's /\x2f nowhere for it to go. get enabled() { return ppixiv.native || !ppixiv.mobile; } _createTabId(recreate=false) { /\x2f If we have a saved tab ID, use it. /\x2f /\x2f sessionStorage on Android Chrome is broken. Home screen apps should retain session storage /\x2f for that particular home screen item, but they don't. (This isn't a problem on iOS.) Use /\x2f localStorage instead, which means things like linked tabs will link to the device instead of /\x2f the instance. That's usually good enough if you're linking to a phone or tablet. let storage = ppixiv.android? localStorage:sessionStorage; if(!recreate && storage.ppixivTabId) return storage.ppixivTabId; /\x2f Make a new ID, and save it to the session. This helps us keep the same ID /\x2f when we're reloaded. storage.ppixivTabId = helpers.other.createUuid(); return storage.ppixivTabId; } _finalizeQuickViewImage = () => { let args = helpers.args.location; if(args.hash.has("temp-view")) { console.log("Finalizing quick view image because we gained focus"); args.hash.delete("virtual"); args.hash.delete("temp-view"); helpers.navigate(args, { addToHistory: false }); } } messages = new EventTarget(); /\x2f If we're sending an image and the page is unloaded, try to cancel it. This is /\x2f only registered when we're sending an image. _windowUnload = (e) => { /\x2f If we were sending an image to another tab, cancel it if this tab is closed. this.sendMessage({ message: "send-image", action: "cancel", to: ppixiv.settings.get("linked_tabs", []), }); } /\x2f Send an image to another tab. action is either "temp-view", to show the image temporarily, /\x2f or "display", to navigate to it. async send_image(mediaId, tabIds, action) { if(tabIds.length == 0) return; /\x2f Send everything we know about the image, so the receiver doesn't have to /\x2f do a lookup. let mediaInfo = ppixiv.mediaCache.getMediaInfoSync(mediaId); let userId = mediaInfo?.userId; let userInfo = userId? ppixiv.userCache.getUserInfoSync(userId):null; this.sendMessage({ message: "send-image", from: this.tabId, to: tabIds, mediaId, action, /\x2f "temp-view" or "display" mediaInfo: mediaInfo?.serialize, userInfo, origin: window.origin, }, false); } _broadcastMediaChanges(mediaId) { /\x2f Don't do this if this is coming from another tab, so we don't re-broadcast data /\x2f we just received. if(this._handlingBroadcastedMediaInfo) return; /\x2f Broadcast the new info to other tabs. this._broadcastImageInfo(mediaId); } /\x2f Send image info to other tabs. We do this when we know about modifications to /\x2f an image that other tabs might be displaying, such as the like count and crop /\x2f info. This isn't done when we simply load image data from the server, so we're /\x2f not constantly sending all search results to all tabs. We don't currently update /\x2f thumbnail data from image data, so if a tab edits image data while it doesn't have /\x2f thumbnail data loaded, other tabs with only thumbnail data loaded won't see it. _broadcastImageInfo(mediaId) { /\x2f Send everything we know about the image, so the receiver doesn't have to /\x2f do a lookup. let mediaInfo = ppixiv.mediaCache.getMediaInfoSync(mediaId); let userId = mediaInfo?.userId; let userInfo = userId? ppixiv.userCache.getUserInfoSync(userId):null; this.sendMessage({ message: "image-info", from: this.tabId, mediaId, mediaInfo: mediaInfo?.serialize, bookmarkTags: ppixiv.extraCache.getBookmarkDetailsSync(mediaId), userInfo, origin: window.origin, }, false); } receivedMessage = async(e) => { let data = e.data; /\x2f If this message has a target and it's not us, ignore it. if(data.to && data.to.indexOf(this.tabId) == -1) return; let event = new Event(data.message); event.message = data; this.messages.dispatchEvent(event); if(data.message == "tab-info") { if(data.from == this.tabId) { /\x2f The other tab has the same ID we do. The only way this normally happens /\x2f is if a tab is duplicated, which will duplicate its sessionStorage with it. /\x2f If this happens, use tab_id_tiebreaker to decide who wins. The tab with /\x2f the higher value will recreate its tab ID. This is set to the time when /\x2f we're loaded, so this will usually cause new tabs to be the one to create /\x2f a new ID. if(this._tabIdTiebreaker >= data.tab_id_tiebreaker) { console.log("Creating a new tab ID due to ID conflict"); this.tabId = this._createTabId(true /* recreate */ ); } else console.log("Tab ID conflict (other tab will create a new ID)"); /\x2f Broadcast info. If we recreated our ID then we want to broadcast it on the /\x2f new ID. If we didn't, we still want to broadcast it to replace the info /\x2f the other tab just sent on our ID. this._broadcastTabInfo(); } } else if(data.message == "list-tabs") { /\x2f A new tab opened, and is asking for other tabs to broadcast themselves to check for /\x2f tab ID conflicts. this._broadcastTabInfo(); } else if(data.message == "send-image") { /\x2f If this message has illust info or thumbnail info and it's on the same origin, /\x2f register it. if(data.origin == window.origin) { console.log("Registering cached image info"); let { mediaInfo, userInfo } = data; if(userInfo != null) ppixiv.userCache.addUserData(userInfo); if(mediaInfo != null) ppixiv.mediaCache.addFullMediaInfo(mediaInfo); } /\x2f To finalize, just remove preview and quick-view from the URL to turn the current /\x2f preview into a real navigation. This is slightly different from sending "display" /\x2f with the illust ID, since it handles navigation during quick view. if(data.action == "finalize") { let args = helpers.args.location; args.hash.delete("virtual"); args.hash.delete("temp-view"); helpers.navigate(args, { addToHistory: false }); return; } if(data.action == "cancel") { this.hidePreviewImage(); return; } /\x2f Otherwise, we're displaying an image. quick-view displays in quick-view+virtual /\x2f mode, display just navigates to the image normally. console.assert(data.action == "temp-view" || data.action == "display", data.actionj); /\x2f Show the image. ppixiv.app.showMediaId(data.mediaId, { tempView: data.action == "temp-view", source: "temp-view", /\x2f When we first show a preview, add it to history. If we show another image /\x2f or finalize the previewed image while we're showing a preview, replace the /\x2f preview history entry. addToHistory: !ppixiv.phistory.virtual, }); } else if(data.message == "image-info") { if(data.origin != window.origin) return; /\x2f We need to make sure that we don't recurse: adding media info will trigger mediamodified, /\x2f which can cause us to come back here and send it again. First flush any waiting mediamodified /\x2f events, since these happen async and we only want to ignore the ones we cause. MediaInfo.flushMediaInfoModifiedCallbacks(); /\x2f addFullMediaInfo will trigger mediamodified below. Make sure we don't rebroadcast /\x2f info that we're receiving here. this._handlingBroadcastedMediaInfo = true; try { /\x2f Another tab is broadcasting updated image info. If we have this image loaded, /\x2f update it. let { mediaInfo, bookmarkTags, userInfo } = data; if(mediaInfo != null) ppixiv.mediaCache.addFullMediaInfo(mediaInfo); if(bookmarkTags != null) ppixiv.extraCache.updateCachedBookmarkTags(data.mediaId, bookmarkTags); if(userInfo != null) ppixiv.userCache.addUserData(userInfo); /\x2f Flush the mediamodified events we just caused before unsetting _handlingBroadcastedMediaInfo. MediaInfo.flushMediaInfoModifiedCallbacks(); } finally { this._handlingBroadcastedMediaInfo = false; } } else if(data.message == "preview-mouse-movement") { /\x2f Ignore this message if we're not displaying a quick view image. if(!ppixiv.phistory.virtual) return; /\x2f The mouse moved in the tab that's sending quick view. Broadcast an event /\x2f like pointermove. We have to work around a stupid pair of bugs: Safari /\x2f doesn't handle setting movementX/movementY in the constructor, and Firefox /\x2f *only* handles it that way, throwing an error if you try to set it manually. let event = new PointerEvent("quickviewpointermove", { movementX: data.x, movementY: data.y, }); if(event.movementX == null) { event.movementX = data.x; event.movementY = data.y; } window.dispatchEvent(event); } } _broadcastTabInfo = () => { let ourTabInfo = { message: "tab-info", tab_id_tiebreaker: this._tabIdTiebreaker, }; this.sendMessage(ourTabInfo); } sendMessage(data, send_to_self) { /\x2f Include the tab ID in all messages. data.from = this.tabId; this._sendImageChannel.postMessage(data); if(send_to_self) { /\x2f Make a copy of data, so we don't modify the caller's copy. data = JSON.parse(JSON.stringify(data)); /\x2f Set self to true to let us know that this is our own message. data.self = true; this._sendImageChannel.dispatchEvent(new MessageEvent("message", { data: data })); } } /\x2f If we're currently showing a preview image sent from another tab, back out to /\x2f where we were before. hidePreviewImage() { let wasInPreview = ppixiv.phistory.virtual; if(!wasInPreview) return; ppixiv.phistory.back(); } sendMouseMovementToLinkedTabs(x, y) { if(!ppixiv.settings.get("linked_tabs_enabled")) return; let tabIds = ppixiv.settings.get("linked_tabs", []); if(tabIds.length == 0) return; this._pendingMovement[0] += x; this._pendingMovement[1] += y; /\x2f Limit the rate we send these, since mice with high report rates can send updates /\x2f fast enough to saturate BroadcastChannel and cause messages to back up. Add up /\x2f movement if we're sending too quickly and batch it into the next message. if(this.lastMovementMessageTime != null && Date.now() - this.lastMovementMessageTime < 10) return; this.lastMovementMessageTime = Date.now(); this.sendMessage({ message: "preview-mouse-movement", x: this._pendingMovement[0], y: this._pendingMovement[1], to: tabIds, }, false); this._pendingMovement = [0, 0]; } }; export class LinkTabsPopup extends Widget { constructor({...options}) { super({...options, classes: "link-tab-popup", template: \` \`}); } /\x2f Send show-link-tab to tell other tabs to display the "link this tab" popup. /\x2f This includes the linked tab list, so they know whether to say "link" or "unlink". sendLinkTabMessage = () => { if(!this.visible) return; ppixiv.sendImage.sendMessage({ message: "show-link-tab", linkedTabs: ppixiv.settings.get("linked_tabs", []), }); } visibilityChanged() { super.visibilityChanged(); if(!this.visible) { ppixiv.sendImage.sendMessage({ message: "hide-link-tab" }); return; } helpers.other.interval(this.sendLinkTabMessage, 1000, this.visibilityAbort.signal); /\x2f Refresh the "unlink all tabs" button on other tabs when the linked tab list changes. ppixiv.settings.addEventListener("linked_tabs", this.sendLinkTabMessage, { signal: this.visibilityAbort.signal }); /\x2f The other tab will send these messages when the link and unlink buttons /\x2f are clicked. ppixiv.sendImage.messages.addEventListener("link-this-tab", (e) => { let message = e.message; let tabIds = ppixiv.settings.get("linked_tabs", []); if(tabIds.indexOf(message.from) == -1) tabIds.push(message.from); ppixiv.settings.set("linked_tabs", tabIds); this.sendLinkTabMessage(); }, this._signal); ppixiv.sendImage.messages.addEventListener("unlink-this-tab", (e) => { let message = e.message; let tabIds = ppixiv.settings.get("linked_tabs", []); let idx = tabIds.indexOf(message.from); if(idx != -1) tabIds.splice(idx, 1); ppixiv.settings.set("linked_tabs", tabIds); this.sendLinkTabMessage(); }, this._signal); } } export class LinkThisTabPopup extends DialogWidget { static setup() { let hideTimer = new Timer(() => { this.visible = false; }); let dialog = null; /\x2f Show ourself when we see a show-link-tab message and hide if we see a /\x2f hide-link-tab-message. ppixiv.sendImage.messages.addEventListener("show-link-tab", ({message}) => { LinkThisTabPopup.other_tab_id = message.from; hideTimer.set(2000); if(dialog != null) return; dialog = new LinkThisTabPopup({ message }); dialog.shutdownSignal.addEventListener("abort", () => { hideTimer.clear(); dialog = null; }); ppixiv.sendImage.messages.addEventListener("hide-link-tab", ({message}) => { /\x2f Close the dialog if it's running. if(dialog) dialog.visible = false; }, dialog._signal); }); } constructor({ message, ...options }={}) { super({...options, dialogClass: "simple-button-dialog", dialogType: "small", /\x2f This dialog is closed when the sending tab closes the link tab interface. allowClose: false, template: \` \${ helpers.createBoxLink({ label: "Link this tab", classes: ["link-this-tab"]}) } \${ helpers.createBoxLink({ label: "Unlink this tab", classes: ["unlink-this-tab"]}) } \` }); this._linkThisTab = this.querySelector(".link-this-tab"); this._unlinkThisTab = this.querySelector(".unlink-this-tab"); this._linkThisTab.hidden = true; this._unlinkThisTab.hidden = true; /\x2f Show ourself when we see a show-link-tab message and hide if we see a /\x2f hide-link-tab-message. ppixiv.sendImage.messages.addEventListener("show-link-tab", ({message}) => this.showLinkTabMessage({message}), this._signal); /\x2f When "link this tab" is clicked, send a link-this-tab message. this._linkThisTab.addEventListener("click", (e) => { ppixiv.sendImage.sendMessage({ message: "link-this-tab", to: [LinkThisTabPopup.other_tab_id] }); /\x2f If we're linked to another tab, clear our linked tab list, to try to make /\x2f sure we don't have weird chains of tabs linking each other. ppixiv.settings.set("linked_tabs", []); }, this._signal); this._unlinkThisTab.addEventListener("click", (e) => { ppixiv.sendImage.sendMessage({ message: "unlink-this-tab", to: [LinkThisTabPopup.other_tab_id] }); }, this._signal); this.showLinkTabMessage({message}); } showLinkTabMessage({message}) { let linked = message.linkedTabs.indexOf(ppixiv.sendImage.tabId) != -1; this._linkThisTab.hidden = linked; this._unlinkThisTab.hidden = !linked; } } export class SendImagePopup extends DialogWidget { constructor({mediaId, ...options}={}) { super({...options, showCloseButton: false, dialogType: "small", template: \`
Click a tab to send the image there
\`}); /\x2f Close if the container is clicked, but not if something inside the container is clicked. this.root.addEventListener("click", (e) => { if(e.target != this.root) return; this.visible = false; }); /\x2f Periodically send show-send-image to tell other tabs to show SendHerePopup. /\x2f If they're clicked, they'll send take-image. helpers.other.interval(() => { /\x2f We should always be visible when this is called. console.assert(this.visible); ppixiv.sendImage.sendMessage({ message: "show-send-image" }); }, 1000, this.shutdownSignal); ppixiv.sendImage.messages.addEventListener("take-image", ({message}) => { let tabId = message.from; ppixiv.sendImage.send_image(mediaId, [tabId], "display"); this.visible = false; }, this._signal); } shutdown() { super.shutdown(); ppixiv.sendImage.sendMessage({ message: "hide-send-image" }); } } export class SendHerePopup extends DialogWidget { static setup() { /\x2f Show ourself when we see a show-link-tab message and hide if we see a /\x2f hide-link-tab-message. let hideTimer = new Timer(() => { this.visible = false; }); let dialog = null; ppixiv.sendImage.messages.addEventListener("show-send-image", ({message}) => { SendHerePopup.other_tab_id = message.from; hideTimer.set(2000); if(dialog == null) { dialog = new SendHerePopup(); dialog.shutdownSignal.addEventListener("abort", () => { hideTimer.clear(); dialog = null; }); } }, this._signal); ppixiv.sendImage.messages.addEventListener("hide-send-image", ({message}) => { /\x2f Close the dialog if it's running. if(dialog) dialog.visible = false; }, this._signal); } constructor({...options}={}) { super({...options, dialogClass: "simple-button-dialog", small: true, /\x2f This dialog is closed when the sending tab closes the send image interface. allowClose: false, template: \` \${ helpers.createBoxLink({ label: "Click to send image here", classes: ["link-this-tab"]}) } \`}); window.addEventListener("click", this.takeImage, { signal: this.shutdownSignal }); } takeImage = (e) => { /\x2f Send take-image. The sending tab will respond with a send-image message. ppixiv.sendImage.sendMessage({ message: "take-image", to: [SendHerePopup.other_tab_id] }); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/send-image.js `), "/vview/misc/settings.js": loadBlob("application/javascript", `/\x2f Get and set values in localStorage. /\x2f /\x2f When a setting changes, an event with the name of the setting is dispatched. import { helpers } from '/vview/misc/helpers.js'; export default class Settings extends EventTarget { constructor() { super(); this.stickySettings = { }; this.sessionSettings = { }; this.defaults = { }; /\x2f We often read settings repeatedly in inner loops, which can become a bottleneck /\x2f if we decode the JSON-encoded settings from localStorage every time. However, we /\x2f don't want to cache them aggressively, since changes to settings in one tab should /\x2f take effect in others immediately. This caches the decoded value of settings, but /\x2f is cleared as soon as we return to the event loop, so we only cache settings briefly. this.cache = { }; /\x2f If a setting has no saved value, it'll be cached as no_value. This is different from /\x2f null, since null is a valid saved value. this.noValue = new Object(); /\x2f Register settings. this.configure("zoom-mode", { sticky: true }); this.configure("zoom-level", { sticky: true }); this.configure("linked_tabs", { session: true }); this.configure("linked_tabs_enabled", { session: true, defaultValue: true }); this.configure("volume", { defaultValue: 1 }); this.configure("view_mode", { defaultValue: "illust" }); this.configure("image_editing", { session: true }); this.configure("image_editing_mode", { session: true }); this.configure("inpaint_create_lines", { session: true }); this.configure("slideshow_duration", { defaultValue: 15 }); this.configure("auto_pan", { defaultValue: ppixiv.mobile }); this.configure("auto_pan_duration", { defaultValue: 3 }); this.configure("slideshow_default", { defaultValue: "pan" }); this.configure("upscaling", { defaultValue: false }); this.configure("extraMutes", { defaultValue: [] }); this.configure("slideshow_skips_manga", { defaultValue: false }); this.configure("pixiv_cdn", { defaultValue: "pixiv" }); /\x2f see helpers.pixiv.pixivImageHosts /\x2f Default to aspect ratio thumbs unless we're on a phone. this.configure("thumbnail_style", { defaultValue: helpers.other.isPhone()? "square":"aspect" }); this.configure("expand_manga_thumbnails", { defaultValue: false }); this.configure("slideshow_framerate", { defaultValue: 60 }); this.configure("animations_enabled", { defaultValue: ppixiv.mobile }); /\x2f If not null, this limits the size of loaded images. this.configure("image_size_limit", { defaultValue: ppixiv.mobile? 4000*4000:null }); /\x2f Translation settings: this.configure("translation_api_url", { defaultValue: "https:/\x2fapi.cotrans.touhou.ai" }); this.configure("translation_low_res", { defaultValue: false }); this.configure("translation_size", { defaultValue: "M" }); this.configure("translation_translator", { defaultValue: "gpt3.5" }); this.configure("translation_direction", { defaultValue: "auto" }); this.configure("translation_language", { defaultValue: "ENG" }); /\x2f Run any one-time settings migrations. this.migrate(); } /\x2f Configure settings. This is used for properties of settings that we need to /\x2f know at startup, so we know where to find them. /\x2f /\x2f Sticky settings are saved and loaded like other settings, but once a setting is loaded, /\x2f changes made by other tabs won't affect this instance. This is used for things like zoom /\x2f settings, where we want to store the setting, but we don't want each tab to clobber every /\x2f other tab every time it's changed. /\x2f /\x2f Session settings are stored in sessionStorage instead of localStorage. These are /\x2f local to the tab. They'll be copied into new tabs if a tab is duplicated, but they're /\x2f otherwise isolated, and lost when the tab is closed. configure(key, {sticky=false, session=false, defaultValue=null}) { if(sticky) { /\x2f Create the key if it doesn't exist. if(this.stickySettings[key] === undefined) this.stickySettings[key] = null; } if(session) this.sessionSettings[key] = true; if(defaultValue != null) this.defaults[key] = defaultValue; } _getStorageForKey(key) { if(this.sessionSettings[key]) return sessionStorage; else return localStorage; } /\x2f Wait until we return to the event loop, then clear any cached settings. async _queueClearCache() { if(this._clearCacheQueued || Object.keys(this.cache).length == 0) return; this._clearCacheQueued = true; try { await helpers.other.sleep(0); this.cache = {}; } finally { this._clearCacheQueued = false; } } _cacheValue(key, value) { this.cache[key] = value; this._queueClearCache(); } _getFromStorage(key, defaultValue) { /\x2f See if we have a cached value. if(key in this.cache) { let value = this.cache[key]; if(value === this.noValue) return defaultValue; else return value; } let storage = this._getStorageForKey(key); let settingKey = "_ppixiv_" + key; if(!(settingKey in storage)) { this._cacheValue(key, this.noValue); return defaultValue; } let result = storage[settingKey]; try { let value = JSON.parse(result); this._cacheValue(key, value); return value; } catch(e) { /\x2f Recover from invalid values in storage. console.warn(e); console.log("Removing invalid setting:", result); delete storage.storage_key; return defaultValue; } } get(key, defaultValue) { if(key in this.defaults) defaultValue = this.defaults[key]; /\x2f If this is a sticky setting and we've already read it, use our loaded value. if(this.stickySettings[key]) return this.stickySettings[key]; let result = this._getFromStorage(key, defaultValue); /\x2f If this is a sticky setting, remember it for reuse. This will store the default value /\x2f if there's no stored setting. if(this.stickySettings[key] !== undefined) this.stickySettings[key] = result; return result; } /\x2f Handle migrating settings that have changed. migrate() { } set(key, value) { let storage = this._getStorageForKey(key); /\x2f JSON.stringify incorrectly serializes undefined as "undefined", which isn't /\x2f valid JSON. We shouldn't be doing this anyway. if(value === undefined) throw "Key can't be set to undefined: " + key; /\x2f If this is a sticky setting, replace its value. if(this.stickySettings[key] !== undefined) this.stickySettings[key] = value; let settingKey = "_ppixiv_" + key; storage[settingKey] = JSON.stringify(value); /\x2f Update the cached value. this._cacheValue(key, value); /\x2f Dispatch the setting name for listeners who want to know when a setting changes. this.dispatchEvent(new Event(key)); /\x2f Dispatch "all" for listeners who want to know when any setting changes. this.dispatchEvent(new Event("all")); } /\x2f Adjust a zoom setting up or down. adjustZoom(setting, down) { let value = this.get(setting); if(typeof(value) != "number" || isNaN(value)) value = 4; value += down?-1:+1; value = helpers.math.clamp(value, 0, 7); this.sliderValue = value; this.value = this.sliderValue; this.set(setting, value); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/settings.js `), "/vview/misc/slideshow.js": loadBlob("application/javascript", `/\x2f This handles the nitty slideshow logic for ViewerImages. /\x2f /\x2f Slideshows can be represented as pans, which is the data editing_pan edits /\x2f and that we save to images. This data is resolution and aspect-ratio independant, /\x2f so it can be applied to different images and used generically. /\x2f /\x2f Slideshows are built into animations using getAnimation, which converts it /\x2f to an animation based on the image's aspect ratio, the screen's aspect ratio, /\x2f the desired speed, etc. import { helpers } from '/vview/misc/helpers.js'; export default class Slideshow { constructor({ /\x2f The size of the image being displayed: width, height, /\x2f The size of the window: containerWidth, containerHeight, /\x2f The minimum zoom level to allow: minimumZoom, /\x2f One of "slideshow", "loop" or "auto-pan". mode, /\x2f The slideshow is normally clamped to the window. This can be disabled by the /\x2f editor. clampToWindow=true, }) { this.width = width; this.height = height; this.containerWidth = containerWidth; this.containerHeight = containerHeight; this.minimumZoom = minimumZoom; this.mode = mode; this.clampToWindow = clampToWindow; } /\x2f Create the default animation. getDefaultAnimation() { if(this.mode == "slideshow") { let slideshowDefault = ppixiv.settings.get("slideshow_default"); if(slideshowDefault == "contain") return this.getAnimation(Slideshow.pans.stationary); else return this.getAnimation(Slideshow.pans.defaultSlideshow); } /\x2f Choose whether to use the horizontal or vertical depending on how the image fits the screen. /\x2f panRatio is < 1 if the image can pan vertically and > 1 if it can pan horizontally. let imageAspectRatio = this.width / this.height; let containerAspectRatio = this.containerWidth / this.containerHeight; let panRatio = imageAspectRatio / containerAspectRatio; /\x2f If the image can move horizontally in the display, use the horizontal pan. Don't use it /\x2f if panRatio is too close to 1 (the image fits the screen), since it doesn't zoom and will /\x2f be stationary. let horizontal = panRatio > 1.1; if(containerAspectRatio < 1) { /\x2f If the monitor and image are both portrait, the portrait animation usually looks better, /\x2f even if the image is less portrait than the monitor and panRatio is > 1. Use a higher /\x2f threshold for portrait monitors so we prefer the portrait animation, even if it cuts off /\x2f some of the image. horizontal = panRatio > 1.5; } let template = horizontal? Slideshow.pans.defaultSlideshowHoldLandscape: Slideshow.pans.defaultSlideshowHoldPortrait; return this.getAnimation(template); } static pans = { /\x2f Zoom from the bottom-left to the top-right, with a slight zoom-in at the beginning. /\x2f For most images, either the horizontal or vertical part of the pan is usually dominant /\x2f and the other goes away, depending on the aspect ratio. The zoom keeps the animation /\x2f from being completely linear. We don't move all the way to the top, since for many /\x2f portrait images that's too far and causes us to pan past the face, fading away while /\x2f looking at the background. /\x2f /\x2f This gives a visually interesting slideshow that works well for most images, and isn't /\x2f very sensitive to aspect ratio and usually does something reasonable whether the image /\x2f or monitor are in landscape or portrait. defaultSlideshow: Object.freeze({ start_zoom: 1.25, end_zoom: 1, x1: 0, y1: 1, x2: 1, y2: 0.1, }), /\x2f The default animations for slideshow-hold mode. If the image can move vertically, /\x2f use a vertical pan with a slight zoom. Otherwise, use a horizontal pan with no zoom. defaultSlideshowHoldPortrait: Object.freeze({ start_zoom: 1.10, end_zoom: 1.00, x1: 0.5, y1: 0.1, x2: 0.5, y2: 1.0, }), defaultSlideshowHoldLandscape: Object.freeze({ x1: 0, y1: 0.5, x2: 1, y2: 0.5, }), /\x2f Display the image statically without panning. stationary: Object.freeze({ start_zoom: 0, end_zoom: 0, x1: 0.5, y1: 0, x2: 0.5, y2: 0, }), } /\x2f Load a saved animation from a description, which is either created with PanEditor or /\x2f programmatically here. If pan is null, return the default animation for the current /\x2f mode. getAnimation(pan) { if(pan == null) return this.getDefaultAnimation(); /\x2f The target duration of the animation: let duration = (this.mode == "slideshow" || this.mode == "loop")? ppixiv.settings.get("slideshow_duration"): ppixiv.settings.get("auto_pan_duration"); /\x2f If we're viewing a very wide or tall image, such as a 1:20 manga strip, it's useful /\x2f to clamp the speed of the animation. If this is a 3-second pan, the image would /\x2f fly past too quickly to see. To adjust for this, we set a maximum speed based on /\x2f the duration. /\x2f /\x2f Scale the max speed based on the duration. With a 5-second duration or less, allow the /\x2f image to move half a screen per second. At 15 seconds or more, slow it down to no more /\x2f than a quarter screen per second. /\x2f /\x2f This usually only has an effect for exceptionally wide images. Most of the time the /\x2f maximum speed ends up being much lower than the actual speed, and we use the duration /\x2f as-is. let maxSpeed = helpers.math.scaleClamp(duration, 5, 15, 0.5, 0.25); let animationData = { duration, maxSpeed, pan: [{ x: pan.x1, y: pan.y1, zoom: pan.start_zoom ?? 1, anchor_x: pan.anchor?.left ?? 0.5, anchor_y: pan.anchor?.top ?? 0.5, }, { x: pan.x2, y: pan.y2, zoom: pan.end_zoom ?? 1, anchor_x: pan.anchor?.right ?? 0.5, anchor_y: pan.anchor?.bottom ?? 0.5, }], }; let animation = this._prepareAnimation(animationData); /\x2f Decide how to ease this animation. if(this.mode == "slideshow") { /\x2f In slideshow mode, we always fade through black, so we don't need any easing on the /\x2f transition. animation.ease = "linear"; } else if(this.mode == "auto-pan") { /\x2f There's no fading in auto-pan mode. Use an ease-out transition, so we start /\x2f quickly and decelerate at the end. We're jumping from another image anyway /\x2f so an ease-in doesn't seem needed. /\x2f /\x2f A standard ease-out is (0, 0, 0.58, 1). We can change the strength of the effect /\x2f by changing the third value, becoming completely linear when it reaches 1. Reduce /\x2f the ease-out effect as the duration gets longer, since longer animations don't need /\x2f the ease-out as much (they're already slow), so we have more even motion. let factor = helpers.math.scaleClamp(animation.duration, 5, 15, 0.58, 1); animation.ease = \`cubic-bezier(0.0, 0.0, \${factor}, 1.0)\`; } else if(this.mode == "loop") { /\x2f Similar to auto-pan, but using an ease-in-out transition instead, and we always keep /\x2f some easing around even for very long animations. let factor = helpers.math.scaleClamp(animation.duration, 5, 15, 0.58, 0.90); animation.ease = \`cubic-bezier(\${1-factor}, 0.0, \${factor}, 1.0)\`; } /\x2f Choose a fade duration. This needs to be quicker if the slideshow is very brief. animation.fadeIn = this.mode == "loop" || this.mode == "slideshow"? Math.min(duration * 0.1, 2.5):0; animation.fadeOut = this.mode == "slideshow"? Math.min(duration * 0.1, 2.5):0; /\x2f If the animation is shorter than the total fade, remove the fade. if(animation.fadeIn + animation.fadeOut > animation.duration) animation.fadeIn = animation.fadeOut = 0; /\x2f For convenience, create KeyframeEffect data. let points = []; for(let point of animation.pan) points.push(\`translateX(\${point.tx}px) translateY(\${point.ty}px) scale(\${point.scale})\`); animation.keyframes = [ { transform: points[0], easing: animation.ease ?? "ease-out", }, { transform: points[1], } ]; return animation; } /\x2f Prepare an animation. This figures out the actual translate and scale for each /\x2f keyframe, and the total duration. The results depend on the image and window /\x2f size. _prepareAnimation(animation) { /\x2f Calculate the scale and translate for each point. let pan = []; for(let point of animation.pan) { /\x2f Don't let the zoom level go below this.minimumZoom. This is usually the zoom /\x2f level where the image covers the screen, and going lower would leave part of /\x2f the screen blank. let scale = Math.max(point.zoom, this.minimumZoom); /\x2f The screen size the image will have: let zoomedWidth = this.width * scale; let zoomedHeight = this.height * scale; /\x2f Initially, the image will be aligned to the top-left of the screen. Shift right and /\x2f down to align the anchor the origin. This is usually the center of the image. let { anchor_x=0.5, anchor_y=0.5 } = point; let tx = this.containerWidth * anchor_x; let ty = this.containerHeight * anchor_y; /\x2f Then shift up and left to center the point: tx -= point.x*zoomedWidth; ty -= point.y*zoomedHeight; if(this.clampToWindow) { /\x2f Clamp the translation to keep the image in the window. This is inverted, since /\x2f tx and ty are transitions and not the image position. let maxX = zoomedWidth - this.containerWidth, maxY = zoomedHeight - this.containerHeight; tx = helpers.math.clamp(tx, 0, -maxX); ty = helpers.math.clamp(ty, 0, -maxY); /\x2f If the image isn't filling the screen on either axis, center it. This only applies at /\x2f keyframes (we won't always be centered while animating). if(zoomedWidth < this.containerWidth) tx = (this.containerWidth - zoomedWidth) / 2; if(zoomedHeight < this.containerHeight) ty = (this.containerHeight - zoomedHeight) / 2; } pan.push({ tx, ty, zoomedWidth, zoomedHeight, scale }); } /\x2f speed is relative to the screen size, so it's not tied too tightly to the resolution /\x2f of the window. A speed of 1 means we want one diagonal screen size per second. /\x2f /\x2f The animation might be translating, or it might be anchored to one corner and just zooming. Treat /\x2f movement speed as the maximum distance any corner is moving. For example, if we're anchored /\x2f in the top-left corner and zooming, the top-left corner is stationary, but the bottom-right /\x2f corner is moving. Use the maximum amount any individual corner is moving as the speed. let corners = []; for(let idx = 0; idx < 2; ++idx) { /\x2f The bounds of the image at each corner: corners.push([ { x: -pan[idx].tx, y: -pan[idx].ty }, { x: -pan[idx].tx, y: -pan[idx].ty + pan[idx].zoomedHeight }, { x: -pan[idx].tx + pan[idx].zoomedWidth, y: -pan[idx].ty }, { x: -pan[idx].tx + pan[idx].zoomedWidth, y: -pan[idx].ty + pan[idx].zoomedHeight }, ]); } let distanceInPixels = 0; for(let corner = 0; corner < 4; ++corner) { let distance = helpers.math.distance(corners[0][corner], corners[1][corner]); distanceInPixels = Math.max(distanceInPixels, distance); } /\x2f The diagonal size of the screen is what our speed is relative to. let screenSize = helpers.math.distance({x: 0, y: 0}, { x: this.containerHeight, y: this.containerWidth }); /\x2f Calculate the duration for keyframes that specify a speed. let duration = animation.duration; if(animation.maxSpeed != null) { /\x2f pixelsPerSecond is the speed we'll move at the given speed. Note that this ignores /\x2f easing, and we'll actually move faster or slower than this during the transition. let speed = Math.max(animation.maxSpeed, 0.01); let pixelsPerSecond = speed * screenSize; let adjustedDuration = distanceInPixels / pixelsPerSecond; /\x2f If both speed and a duration were specified, use whichever is slower. duration = Math.max(animation.duration, adjustedDuration); /\x2f If we set the speed to 0, then we're not moving at all. Set a small duration /\x2f to avoid division by zero. if(duration == 0) duration = 0.1; } return { pan, duration, }; } static makeFadeIn(target, options) { return new Animation(new KeyframeEffect( target, [ { opacity: 0, offset: 0 }, { opacity: 1, offset: 1 }, ], { fill: 'forwards', ...options } )); } static makeFadeOut(target, options) { return new Animation(new KeyframeEffect( target, [ { opacity: 1, offset: 0 }, { opacity: 0, offset: 1 }, ], { fill: 'forwards', ...options } )); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/slideshow.js `), "/vview/misc/tag-translations.js": loadBlob("application/javascript", `import KeyStorage from '/vview/misc/key-storage.js'; import { helpers } from '/vview/misc/helpers.js'; export default class TagTranslations { constructor() { this._db = new KeyStorage("ppixiv-tag-translations"); /\x2f Firefox's private mode is broken: instead of making storage local to the session and /\x2f not saved to disk, it just disables IndexedDB entirely, which is lazy and breaks pages. /\x2f Keep a copy of tags we've seen in this session to work around this This isn't a problem /\x2f in other browsers. this._cache = new Map(); } /\x2f Return true if translations are enabled by the user. get enabled() { return !ppixiv.settings.get("disable-translations"); } /\x2f Store a list of tag translations. /\x2f /\x2f tags is a dictionary: /\x2f { /\x2f original_tag: { /\x2f en: "english tag", /\x2f } /\x2f } async addTranslationsDict(tags) { let translations = []; for(let tag of Object.keys(tags)) { let tagInfo = tags[tag]; let tagTranslation = {}; for(let lang of Object.keys(tagInfo)) { if(tagInfo[lang] == "") continue; tagTranslation[lang] = tagInfo[lang]; } if(Object.keys(tagTranslation).length > 0) { translations.push({ tag: tag, translation: tagTranslation, }); } } this.addTranslations(translations); } /\x2f Store a list of tag translations. /\x2f /\x2f tagList is a list of /\x2f { /\x2f tag: "original tag", /\x2f translation: { /\x2f en: "english tag", /\x2f }, /\x2f } /\x2f /\x2f This is the same format that Pixiv uses in newer APIs. Note that we currently only store /\x2f English translations. async addTranslations(tagList) { let data = {}; for(let tag of tagList) { /\x2f If a tag has no keys and no romanization, skip it so we don't fill our database /\x2f with useless entries. if((tag.translation == null || Object.keys(tag.translation).length == 0) && tag.romaji == null) continue; /\x2f Remove empty translation values. let translation = {}; for(let lang of Object.keys(tag.translation || {})) { let value = tag.translation[lang]; if(value != "") translation[lang] = value; } /\x2f Store the tag data that we care about. We don't need to store post-specific info /\x2f like "deletable". let tagInfo = { tag: tag.tag, translation: translation, }; if(tag.romaji) tagInfo.romaji = tag.romaji; data[tag.tag] = tagInfo; if(translation.en) this._cache.set(tag.tag, translation.en); } /\x2f Batch write: await this._db.multiSet(data); } async getTagInfo(tags) { /\x2f If the user has disabled translations, don't return any. if(!this.enabled) return {}; let result = {}; let translations = await this._db.multiGet(tags); for(let i = 0; i < tags.length; ++i) { if(translations[i] == null) continue; result[tags[i]] = translations[i]; } return result; } async getTranslations(tags, language="en") { if(!this.enabled) return {}; let info = await this.getTagInfo(tags); let result = {}; for(let tag of tags) { if(info[tag] == null || info[tag].translation == null) continue; /\x2f 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; } /\x2f See if we have cached translations for tags not in the database. for(let tag of tags) { if(result[tag]) continue; result[tag] = this._cache.get(tag); } return result; } /\x2f Given a tag search, return a translated search. async translateTagList(tags, language) { /\x2f Pull out individual tags, removing -prefixes. let splitTags = helpers.pixiv.splitSearchTags(tags); let tagList = []; for(let tag of splitTags) { let [prefix, unprefixedTag] = helpers.pixiv.splitTagPrefixes(tag); tagList.push(unprefixedTag); } /\x2f Get translations. let translatedTags = await this.getTranslations(tagList, language); /\x2f Put the search back together. let result = []; for(let oneTag of splitTags) { let prefixAndTag = helpers.pixiv.splitTagPrefixes(oneTag); let prefix = prefixAndTag[0]; let tag = prefixAndTag[1]; if(translatedTags[tag]) tag = translatedTags[tag]; result.push(prefix + tag); } return result; } /\x2f A shortcut to retrieve one translation. If no translation is available, returns the /\x2f original tag. async getTranslation(tag, language="en") { let result = this._cache.get(tag); if(result != null) return result; let translatedTags = await this.getTranslations([tag], "en"); if(translatedTags[tag]) return translatedTags[tag]; else return tag; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/tag-translations.js `), "/vview/misc/user-cache.js": loadBlob("application/javascript", `/\x2f Lookup and caching for user data. import { helpers } from '/vview/misc/helpers.js'; export default class UserCache extends EventTarget { constructor() { super(); this._userData = { }; this._allUserFollowTags = null; this._userFollowInfo = { }; this._userInfoLoads = {}; this._followInfoLoads = {}; this._userFollowTagsLoad = null; this._userProfile = { } this._userProfileLoads = { } this._nonexistantUserIds = { }; this._userBoothUrls = { }; } async getUserIdForMediaId(mediaId) { if(mediaId == null) return null; /\x2f If the media ID is a user ID, use it. let { type, id } = helpers.mediaId.parse(mediaId); if(type == "user") return id; /\x2f Fetch media info. We don't need to coalesce these requests if this is called /\x2f multiple times, since MediaCache will do that for us. let mediaInfo = await ppixiv.mediaCache.getMediaInfo(mediaId, { full: false }); return mediaInfo?.userId; } /\x2f Fire usermodified to let listeners know that a user's info changed. callUserModifiedCallbacks(userId) { console.log(\`User modified: \${userId}\`); let event = new Event("usermodified"); event.userId = userId; this.dispatchEvent(event); } getUserLoadError(userId) { return this._nonexistantUserIds[userId]; } /\x2f The user request can either return a small subset of data (just the username, /\x2f profile image URL, etc.), or full data with a webpage URL, Twitter, etc. User /\x2f preloads often only have the smaller set, and we want to use the preload data /\x2f whenever possible. async getUserInfo(userId, {full=false}={}) { return await this._getUserInfo(userId, full); } /\x2f Return user info for userId if it's already cached, otherwise return null. getUserInfoSync(userId, {full=false}={}) { let userInfo = this._userData[userId]; if(userInfo == null) return null; /\x2f If full info was requested and we only have partial info, don't return it. /\x2f (Note that Pixiv's "partial" flag is backwards.) if(full && !userInfo.partial) return null; return userInfo; } /\x2f Load userId if needed. /\x2f /\x2f If loadFullData is false, it means the caller only needs partial data, and we /\x2f won't send a request if we already have that, but if we do end up loading the /\x2f user we'll always load full data. /\x2f /\x2f Some sources only give us partial data, which only has a subset of keys. See /\x2f _checkUserData for the keys available with partial and full data. _getUserInfo(userId, loadFullData) { if(userId == null) return null; /\x2f Stop if we know this user doesn't exist. let baseMediaId = \`user:\${userId}\`; if(baseMediaId in this._nonexistantUserIds) return null; /\x2f If we already have the user info for this illustration (and it's full data, if /\x2f requested), we're done. if(this._userData[userId] != null) { /\x2f userInfo.partial is 1 if it's the full data (this is backwards). If we need /\x2f full data and we only have partial data, we still need to request data. if(!loadFullData || this._userData[userId].partial) { return new Promise(resolve => { resolve(this._userData[userId]); }); } } /\x2f If there's already a load in progress, just return it. if(this._userInfoLoads[userId] != null) return this._userInfoLoads[userId]; this._userInfoLoads[userId] = this._loadUserInfo(userId); this._userInfoLoads[userId].then(() => { delete this._userInfoLoads[userId]; }); return this._userInfoLoads[userId]; }; async _loadUserInfo(userId) { /\x2f -1 is for illustrations with no user, which is used for local images. if(userId == -1) return null; /\x2f console.log("Fetch user", userId); let result = await helpers.pixivRequest.get(\`/ajax/user/\${userId}\`, {full:1}); if(result == null || result.error) { let message = result?.message || "Error loading user"; console.log(\`Error loading user \${userId}: \${message}\`); this._nonexistantUserIds[\`user:\${userId}\`] = message; return null; } return this._loadedUserInfo(result); } /\x2f Add user data that we received from other sources. addUserData(userData) { this._loadedUserInfo({ body: userData, }); } _loadedUserInfo = (userResult) => { if(userResult.error) return; let userData = userResult.body; userData = this._checkUserData(userData); let userId = userData.userId; /\x2f console.log("Got user", userId); /\x2f Store the user data. if(this._userData[userId] == null) this._userData[userId] = userData; else { /\x2f If we already have an object for this user, we're probably replacing partial user data /\x2f with full user data. Don't replace the userData object itself, since widgets will have /\x2f a reference to the old one which will become stale. Just replace the data inside the /\x2f object. let oldUserData = this._userData[userId]; for(let key of Object.keys(oldUserData)) delete oldUserData[key]; for(let key of Object.keys(userData)) oldUserData[key] = userData[key]; } return userData; } _checkUserData(userData) { /\x2f Make sure that the data contains all of the keys we expect, so we catch any unexpected /\x2f missing data early. Discard keys that we don't use, to make sure we update this if we /\x2f make use of new keys. This makes sure that the user data keys are always consistent. let fullKeys = [ 'userId', 'background', /\x2f 'image', 'imageBig', /\x2f 'isBlocking', 'isFollowed', 'isMypixiv', 'name', 'partial', 'social', 'commentHtml', 'acceptRequest', /\x2f 'premium', /\x2f 'sketchLiveId', /\x2f 'sketchLives', ]; let partialKeys = [ 'userId', 'isFollowed', 'name', 'imageBig', 'partial', ]; /\x2f partial is 0 if this is partial user data and 1 if it's full data (this is backwards). let expectedKeys = userData.partial? fullKeys:partialKeys; let remappedUserData = { }; for(let key of expectedKeys) { if(!(key in userData)) { console.warn("User info is missing key:", key); continue; } remappedUserData[key] = userData[key]; } return remappedUserData; } /\x2f User profiles are separate from user info. getUserProfile(userId) { if(userId == null) return null; if(this._userProfile[userId]) return this._userProfile[userId]; /\x2f Stop if we know this user doesn't exist. let baseMediaId = \`user:\${userId}\`; if(baseMediaId in this._nonexistantUserIds) return null; /\x2f If there's already a load in progress, just return it. if(this._userProfileLoads[userId] != null) return this._userProfileLoads[userId]; this._userProfileLoads[userId] = this._loadUserProfile(userId); this._userProfileLoads[userId].then(() => { delete this._userProfileLoads[userId]; }); return this._userProfileLoads[userId]; } getUserProfileSync(userId) { return this._userProfile[userId]; } async _loadUserProfile(userId) { /\x2f -1 is for illustrations with no user, which is used for local images. if(userId == -1) return null; /\x2f console.log("Fetch user", userId); let result = helpers.pixivRequest.get(\`/ajax/user/\${userId}/profile/all\`); if(result == null || result.error) { let message = result?.message || "Error loading user"; console.log(\`Error loading user \${userId}: \${message}\`); this._nonexistantUserIds[\`user:\${userId}\`] = message; return null; } this._userProfile[userId] = result; return result; } /\x2f Return the URL to a user's Booth page, if any. The results are cached. getUserBoothUrl(userId) { /\x2f Stop if this has already been loaded. Note that _userBoothUrls[userId] /\x2f may be null. if(userId in this._userBoothUrls) return this._userBoothUrls[userId]; let promise = this._loadUserBoothUrl(userId); promise.then((url) => this._userBoothUrls[userId] = url); return promise; } async _loadUserBoothUrl(userId) { /\x2f Check if the user's profile says he has a Booth account first. let userProfile = await ppixiv.userCache.getUserProfile(userId); if(!userProfile.body?.externalSiteWorksStatus?.booth) return null; let boothInfo = await helpers.pixivRequest.get("https:/\x2fapi.booth.pm/pixiv/shops/show.json", { pixiv_user_id: userId, adult: "exclude", /\x2f We don't need item results, but 1 is the minimum. limit: 1, }); if(boothInfo.error) return null; return boothInfo.body.url; } /\x2f Load the follow info for a followed user, which includes follow tags and whether the /\x2f follow is public or private. If the user isn't followed, return null. /\x2f /\x2f This can also fetch the results of loadAllUserFollowTags and will cache it if /\x2f available, so if you're calling both getUserFollowInfo and loadAllUserFollowTags, /\x2f call this first. async getUserFollowInfo(userId, { refresh=false }={}) { /\x2f If we request following info for a user we're not following, we'll get a 400. This /\x2f isn't great, since it means we have to make an extra API call first to see if we're /\x2f following to avoid spamming request errors. let userData = await this.getUserInfo(userId); if(!userData.isFollowed) { delete this._userFollowInfo[userId]; return null; } /\x2f Stop if this user's follow info is already loaded. if(!refresh && this._userFollowInfo[userId]) return this._userFollowInfo[userId]; /\x2f If another request is already running for this user, wait for it to finish and use /\x2f its result. if(this._followInfoLoads[userId]) { await this._followInfoLoads[userId]; return this._userFollowInfo[userId]; } this._followInfoLoads[userId] = helpers.pixivRequest.get("/ajax/following/user/details", { user_id: userId, lang: "en", }); let data = await this._followInfoLoads[userId]; this._followInfoLoads[userId] = null; if(data.error) { console.log(\`Couldn't request follow info for \${userId}\`); return null; } /\x2f This returns both selected tags and all follow tags, so we can also update /\x2f _userFollowInfo. let allTags = []; let tags = new Set(); for(let tagInfo of data.body.tags) { allTags.push(tagInfo.name); if(tagInfo.selected) tags.add(tagInfo.name); } this._setCachedAllUserFollowTags(allTags); this._userFollowInfo[userId] = { tags, followingPrivately: data.body.restrict == "1", } return this._userFollowInfo[userId]; } getUserFollowInfoSync(userId) { return this._userFollowInfo[userId]; } /\x2f Load all of the user's follow tags. This is cached unless refresh is true. async loadAllUserFollowTags({ refresh=false }={}) { /\x2f Follow tags require premium. if(!ppixiv.pixivInfo.premium) return []; if(!refresh && this._allUserFollowTags != null) return this._allUserFollowTags; /\x2f If another call is already running, wait for it to finish and use its result. if(this._userFollowTagsLoad) { await this._userFollowTagsLoad; return this._allUserFollowTags; } /\x2f The only ways to get this list seem to be from looking at an already-followed /\x2f user, or looking at the follow list. this._userFollowTagsLoad = helpers.pixivRequest.get(\`/ajax/user/\${ppixiv.pixivInfo.userId}/following\`, { offset: 0, limit: 1, rest: "show", }); let result = await this._userFollowTagsLoad; this._userFollowTagsLoad = null; if(result.error) console.log("Error retrieving follow tags"); else this._setCachedAllUserFollowTags(result.body.followUserTags); return this._allUserFollowTags; } /\x2f Update the list of tags we've followed a user with. _setCachedAllUserFollowTags(tags) { tags.sort(); /\x2f Work around a Pixiv bug. If you ever use the follow user API with a tag /\x2f of null (instead of ""), it returns an internal error and you end up with /\x2f a "null" tag in your tag list that never goes away. It seems like it stores /\x2f the actual null value, which then gets coerced to the string "null" in the /\x2f API. Remove it, since it's annoying (sorry if you really wanted to tag /\x2f people as "null"). let idx = tags.indexOf("null"); if(idx != -1) tags.splice(idx, 1); this._allUserFollowTags = tags; } /\x2f Add a new tag to _allUserFollowTags when the user creates a new one. addCachedUserFollowTags(tag) { if(this._allUserFollowTags == null || this._allUserFollowTags.indexOf(tag) != -1) return; this._allUserFollowTags.push(tag); this._allUserFollowTags.sort(); } /\x2f Return the list of the user's follow tags if it's been loaded, otherwise return null. getAllUserFollowTagsSync() { return this._allUserFollowTags; } /\x2f Update the follow info for a user. This is used after updating a follow. updateCachedFollowInfo(userId, followed, followInfo) { /\x2f If user info isn't loaded, follow info isn't either. let userInfo = this.getUserInfoSync(userId); if(userInfo == null) return; userInfo.isFollowed = followed; if(!followed) { delete this._userFollowInfo[userId]; } else { this._userFollowInfo[userId] = followInfo; } this.callUserModifiedCallbacks(userId); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/user-cache.js `), "/vview/misc/zip-image-downloader.js": loadBlob("application/javascript", `/\x2f Download a ZIP, returning files as they download in the order they're stored /\x2f in the ZIP. /\x2f A wrapper for the clunky ReadableStream API that lets us do at basic /\x2f thing that API forgot about: read a given number of bytes at a time. import { helpers } from '/vview/misc/helpers.js'; class IncrementalReader { constructor(reader, options={}) { this.reader = reader; this.position = 0; /\x2f Check if this is an ArrayBuffer. "reader instanceof ArrayBuffer" is /\x2f broken in Firefox (but what isn't?). if("byteLength" in reader) { this.inputBuffer = new Int8Array(reader); this.inputBufferFinished = true; } else { this.inputBuffer = new Int8Array(0); this.inputBufferFinished = false; } /\x2f If set, this is called with the current read position as we read data. this.onprogress = options.onprogress; } async read(bytes) { let buffer = new ArrayBuffer(bytes); let result = new Int8Array(buffer); let outputPos = 0; while(outputPos < bytes) { /\x2f See if we have leftover data in this.inputBuffer. if(this.inputBuffer.byteLength > 0) { /\x2f Create a view of the bytes we want to copy, then use set() to copy them to the /\x2f output. This is just memcpy(), why can't you just set(buf, srcPos, srcLen, dstPos)? let copyBytes = Math.min(bytes-outputPos, this.inputBuffer.byteLength); let buf = new Int8Array(this.inputBuffer.buffer, this.inputBuffer.byteOffset, copyBytes); result.set(buf, outputPos); outputPos += copyBytes; /\x2f Remove the data we read from the buffer. This is just making the view smaller. this.inputBuffer = new Int8Array(this.inputBuffer.buffer, this.inputBuffer.byteOffset + copyBytes); continue; } /\x2f If we need more data and there isn't any, we've passed EOF. if(this.inputBufferFinished) throw new Error("Incomplete file"); let { value, done } = await this.reader.read(); if(value == null) value = new Int8Array(0); this.inputBufferFinished = done; this.inputBuffer = value; if(value) this.position += value.length; if(this.onprogress) this.onprogress(this.position); }; return buffer; } } export default class ZipImageDownloader { constructor(url, options={}) { this.url = url; /\x2f An optional AbortSignal. this.signal = options.signal; this.onprogress = options.onprogress; this.startPromise = this.start(); } async start() { let response = await helpers.pixivRequest.sendPixivRequest({ method: "GET", url: this.url, responseType: "arraybuffer", signal: this.signal, }); /\x2f If this fails, the error was already logged. The most common cause is being cancelled. if(response == null) return null; /\x2f We could also figure out progress from frame numbers, but doing it with the actual /\x2f amount downloaded is more accurate, and the server always gives us content-length. this.totalLength = response.headers.get("Content-Length"); if(this.totalLength != null) this.totalLength = parseInt(this.totalLength); /\x2f Firefox is in the dark ages and can't stream data from fetch. Fall back /\x2f on loading the whole body if we don't have getReader. let fetchReader; if(response.body.getReader) fetchReader = response.body.getReader(); else fetchReader = await response.arrayBuffer(); this.reader = new IncrementalReader(fetchReader, { onprogress: (position) => { if(this.onprogress && this.totalLength > 0) { let progress = position / this.totalLength; this.onprogress(progress); } } }); } async getNextFrame() { /\x2f Wait for startPromise to complete, if it hasn't yet. await this.startPromise; if(this.reader == null) return null; /\x2f Read the local file header up to the filename. let header = await this.reader.read(30); let view = new DataView(header); /\x2f Check the header. let magic = view.getUint32(0, true); if(magic == 0x02014b50) { /\x2f Once we see the central directory, we're at the end. return null; } if(magic != 0x04034b50) throw Error("Unrecognized file"); let compression = view.getUint16(8, true); if(compression != 0) throw Error("Unsupported compression method"); /\x2f Get the variable field lengths, and skip over the rest of the local file headers. let fileSize = view.getUint32(22, true); let filenameSize = view.getUint16(26, true); let extraSize = view.getUint16(28, true); await this.reader.read(filenameSize); await this.reader.read(extraSize); /\x2f Read the file. let result = await this.reader.read(fileSize); /\x2f Read past the data descriptor if this file has one. let flags = view.getUint16(6, true); if(flags & 8) { let descriptor = await this.reader.read(16); let descriptorView = new DataView(descriptor); if(descriptorView.getUint32(0, true) != 0x08074b50) throw Error("Unrecognized file"); } return result; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/misc/zip-image-downloader.js `), "/vview/screen-illust/desktop-image-info.js": loadBlob("application/javascript", `/\x2f This handles the desktop overlay UI on the illustration page. import Widget from '/vview/widgets/widget.js'; import { BookmarkButtonWidget, LikeButtonWidget } from '/vview/widgets/illust-widgets.js'; import { BookmarkTagDropdownOpener } from '/vview/widgets/bookmark-tag-list.js'; import { AvatarWidget } from '/vview/widgets/user-widgets.js'; import { SettingsDialog } from '/vview/widgets/settings-widgets.js'; import Actions from '/vview/misc/actions.js'; import TagListWidget from '/vview/widgets/tag-list-widget.js'; import LocalAPI from '/vview/misc/local-api.js'; import { getUrlForMediaId } from '/vview/misc/media-ids.js' import { helpers, ClassFlags } from '/vview/misc/helpers.js'; export default class DesktopImageInfo extends Widget { constructor({...options}) { super({ ...options, visible: false, template: \` \`}); /\x2f ui-box is the real container. THe outer div is just so hover-sphere isn't inside /\x2f the scroller. this.ui_box = this.root.querySelector(".ui-box"); this.avatarWidget = new AvatarWidget({ container: this.root.querySelector(".avatar-popup"), dropdownvisibilitychanged: () => { this.refreshOverlayUiVisibility(); }, }); this.tagListWidget = new TagListWidget({ container: this.root.querySelector(".tag-list-container") }); ppixiv.mediaCache.addEventListener("mediamodified", this.refresh, { signal: this.shutdownSignal }); this.likeButton = new LikeButtonWidget({ container: this.root.querySelector(".button-like-container"), template: \` \` }); this.mangaPageBar = this.querySelector(".manga-page-bar"); /\x2f The bookmark buttons, and clicks in the tag dropdown: this.bookmarkButtons = []; for(let a of this.root.querySelectorAll("[data-bookmark-type]")) this.bookmarkButtons.push(new BookmarkButtonWidget({ container: a, template: \` \`, bookmarkType: a.dataset.bookmarkType, })); let bookmarkTagsButton = this.root.querySelector(".button-bookmark-tags"); this.bookmarkTagsDropdownOpener = new BookmarkTagDropdownOpener({ parent: this, bookmarkTagsButton, bookmarkButtons: this.bookmarkButtons, /\x2f The dropdown affects visibility, so refresh when it closes. onvisibilitychanged: () => { this.refreshOverlayUiVisibility(); }, }); for(let button of this.root.querySelectorAll(".download-button")) button.addEventListener("click", this.clickedDownload); this.root.querySelector(".download-manga-button").addEventListener("click", this.clickedDownload); this.root.querySelector(".view-manga-button").addEventListener("click", (e) => { if(this.mediaId == null) return; let args = getUrlForMediaId(this.mediaId, { manga: true }); helpers.navigate(args); }); /\x2f Don't propagate wheel events if the contents can scroll, so moving the scroller doesn't change the /\x2f image. Most of the time the contents will fit, so allow changing the page if there's no need to /\x2f scroll. this.ui_box.addEventListener("wheel", (e) => { if(this.ui_box.scrollHeight > this.ui_box.offsetHeight) e.stopPropagation(); }, { passive: false }); this.root.querySelector(".preferences-button").addEventListener("click", (e) => { new SettingsDialog(); }); /\x2f Show on hover. this.ui_box.addEventListener("mouseenter", (e) => { this.hoveringOverBox = true; this.refreshOverlayUiVisibility(); }); this.ui_box.addEventListener("mouseleave", (e) => { this.hoveringOverBox = false; this.refreshOverlayUiVisibility(); }); let hoverCircle = this.querySelector(".hover-circle"); hoverCircle.addEventListener("mouseenter", (e) => { this.hoveringOverSphere = true; this.refreshOverlayUiVisibility(); }); hoverCircle.addEventListener("mouseleave", (e) => { this.hoveringOverSphere = false; this.refreshOverlayUiVisibility(); }); ppixiv.settings.addEventListener("image_editing", () => { this.refreshOverlayUiVisibility(); }); ppixiv.settings.addEventListener("image_editing_mode", () => { this.refreshOverlayUiVisibility(); }); ClassFlags.get.addEventListener("hide-ui", () => this.refreshOverlayUiVisibility(), this._signal); this.refreshOverlayUiVisibility(); } refreshOverlayUiVisibility() { /\x2f Hide widgets inside the hover UI when it's hidden. let visible = this.hoveringOverBox || this.hoveringOverSphere; /\x2f Don't show the hover UI while editing, since it can get in the way of trying to /\x2f click the image. let editing = ppixiv.settings.get("image_editing") && ppixiv.settings.get("image_editing_mode") != null; if(editing) visible = false; /\x2f Stay visible if the bookmark tag dropdown or the follow dropdown are visible. if(this.bookmarkTagsDropdownOpener?.visible || this.avatarWidget.followDropdownOpener.visible) visible = true; if(ClassFlags.get.get("hide-ui")) visible = false; /\x2f Tell the image UI when it's visible. this.visible = visible; /\x2f Hide the UI's container too when we're editing, so the hover boxes don't get in /\x2f the way. this.root.hidden = editing || ppixiv.mobile; } applyVisibility() { helpers.html.setClass(this.root.querySelector(".ui-box"), "ui-hidden", !this._visible); } visibilityChanged() { super.visibilityChanged(); this.refresh(); } set dataSource(dataSource) { if(this._dataSource == dataSource) return; this._dataSource = dataSource; this.refresh(); } get mediaId() { return this._mediaId; } set mediaId(mediaId) { if(this._mediaId == mediaId) return; this._mediaId = mediaId; this.refresh(); } get displayedPage() { return helpers.mediaId.parse(this._mediaId).page; } handleKeydown(e) { } refresh = async() => { helpers.html.setClass(this.root, "disabled", !this.visible); /\x2f Don't do anything if we're not visible. if(!this.visible) return; /\x2f Update widget illust IDs. this.likeButton.setMediaId(this._mediaId); for(let button of this.bookmarkButtons) button.setMediaId(this._mediaId); this.bookmarkTagsDropdownOpener.setMediaId(this._mediaId); if(this._mediaId == null) return; /\x2f Try to fill in as much of the UI as we can without waiting for the info to load. this._setPostInfo(); /\x2f We need image info to update. let mediaId = this._mediaId; let mediaInfo = await ppixiv.mediaCache.getMediaInfo(this._mediaId); /\x2f Check if anything changed while we were loading. if(mediaInfo == null || mediaId != this._mediaId || !this.visible) return; /\x2f Fill in the post info text. this._setPostInfo(); } /\x2f Set all fields that we can from partial media info. This lets us refresh most of the UI /\x2f without waiting for the full info to finish loading. _setPostInfo() { /\x2f Get all data that we've loaded so far. This can return full or partial info, so we need /\x2f to check mediaInfo.full in each place we're accessing full data fields. let mediaInfo = ppixiv.mediaCache.getMediaInfoSync(this._mediaId, { full: false, safe: false }); if(mediaInfo == null) return; this.mangaPageBar.hidden = mediaInfo.pageCount == 1; if(mediaInfo.pageCount > 1) { let fill = (this.displayedPage+1) / mediaInfo.pageCount; this.mangaPageBar.style.width = (fill * 100) + "%"; } let [illustId] = helpers.mediaId.toIllustIdAndPage(this._mediaId); let userId = mediaInfo.userId; /\x2f Show the author if it's someone else's post, or the edit link if it's ours. let ourPost = ppixiv.pixivInfo?.userId == userId; this.querySelector(".author-block").hidden = ourPost; this.querySelector(".edit-post").hidden = !ourPost; this.querySelector(".edit-post").href = "/member_illust_mod.php?mode=mod&illust_id=" + illustId; /\x2f Update the disable UI button to point at the current image's illustration page. let disableButton = this.querySelector(".disable-ui-button"); disableButton.href = \`/artworks/\${illustId}#no-ppixiv\`; this.avatarWidget.setUserId(userId); this.avatarWidget.visible = userId != null; this.tagListWidget.set(mediaInfo); let elementTitle = this.root.querySelector(".title"); elementTitle.textContent = mediaInfo.illustTitle; elementTitle.href = getUrlForMediaId(this._mediaId).url; /\x2f Show the folder if we're viewing a local image. let folderTextElement = this.root.querySelector(".folder-text"); let showFolder = helpers.mediaId.isLocal(this._mediaId); if(showFolder) { let {id} = helpers.mediaId.parse(this.mediaId); folderTextElement.innerText = helpers.strings.getPathSuffix(id, 2, 1); /\x2f last two parent directories let parentFolderId = LocalAPI.getParentFolder(id); let args = new helpers.args("/", ppixiv.plocation); LocalAPI.getArgsForId(parentFolderId, args); folderTextElement.href = args.url; } /\x2f If the author name or folder are empty, hide it instead of leaving it empty. this.root.querySelector(".author-block").hidden = mediaInfo.userName == ""; this.root.querySelector(".folder-block").hidden = !showFolder; let elementAuthor = this.root.querySelector(".author"); elementAuthor.href = \`/users/\${userId}#ppixiv\`; if(mediaInfo.userName != "") elementAuthor.textContent = mediaInfo.userName; this.root.querySelector(".similar-illusts-button").href = "/bookmark_detail.php?illust_id=" + illustId + "#ppixiv?recommendations=1"; this.root.querySelector(".similar-artists-button").href = "/discovery/users#ppixiv?user_id=" + userId; this.root.querySelector(".similar-bookmarks-button").href = "/bookmark_detail.php?illust_id=" + illustId + "#ppixiv"; /\x2f Pixiv image descriptions can contain HTML, which we don't need to check. Don't /\x2f display local image descriptions as HTML, since they come directly from image /\x2f files and we don't do any verification of their contents. let elementComment = this.root.querySelector(".description"); elementComment.hidden = !mediaInfo.full || mediaInfo.illustComment == ""; if(ppixiv.native) elementComment.innerText = mediaInfo.full? mediaInfo.illustComment:""; else elementComment.innerHTML = mediaInfo.full? mediaInfo.illustComment:""; helpers.pixiv.fixPixivLinks(elementComment); if(!ppixiv.native) helpers.pixiv.makePixivLinksInternal(elementComment); /\x2f Set the download button popup text. let downloadImageButton = this.root.querySelector(".download-image-button"); downloadImageButton.hidden = !Actions.isDownloadTypeAvailable("image", mediaInfo); let downloadMangaButton = this.root.querySelector(".download-manga-button"); downloadMangaButton.hidden = !Actions.isDownloadTypeAvailable("ZIP", mediaInfo); let downloadVideoButton = this.root.querySelector(".download-video-button"); downloadVideoButton.hidden = !Actions.isDownloadTypeAvailable("MKV", mediaInfo); let postInfoContainer = this.root.querySelector(".post-info"); let setInfo = (query, text) => { let node = postInfoContainer.querySelector(query); node.innerText = text; node.hidden = text == ""; }; let seconds_old = (new Date() - new Date(mediaInfo.createDate)) / 1000; setInfo(".post-age", helpers.strings.ageToString(seconds_old)); postInfoContainer.querySelector(".post-age").dataset.popup = helpers.strings.dateToString(mediaInfo.createDate); let info = ""; /\x2f Add the resolution and file type if available. if(this.displayedPage != null && mediaInfo.full) { let pageInfo = mediaInfo.mangaPages[this.displayedPage]; info += pageInfo.width + "x" + pageInfo.height; /\x2f For illusts, add the image type. Don't do this for animations. if(mediaInfo.illustType != 2) { let url = new URL(pageInfo.urls?.original); let ext = helpers.strings.getExtension(url.pathname).toUpperCase(); if(ext) info += " " + ext; } } setInfo(".image-info", info); let duration = ""; if(mediaInfo.full && mediaInfo.ugoiraMetadata) { let seconds = 0; for(let frame of mediaInfo.ugoiraMetadata.frames) seconds += frame.delay / 1000; duration = seconds.toFixed(duration >= 10? 0:1); duration += seconds == 1? " second":" seconds"; } setInfo(".ugoira-duration", duration); setInfo(".ugoira-frames", (mediaInfo.full && mediaInfo.ugoiraMetadata)? (mediaInfo.ugoiraMetadata.frames.length + " frames"):""); /\x2f Add the page count for manga. let pageText = ""; if(mediaInfo.pageCount > 1 && this.displayedPage != null) pageText = "Page " + (this.displayedPage+1) + "/" + mediaInfo.pageCount; setInfo(".page-count", pageText); } clickedDownload = (e) => { if(this._mediaId == null) return; let clickedButton = e.target.closest(".download-button"); if(clickedButton == null) return; e.preventDefault(); e.stopPropagation(); let downloadType = clickedButton.dataset.download; Actions.downloadIllust(this._mediaId, downloadType, this.displayedPage); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/screen-illust/desktop-image-info.js `), "/vview/screen-illust/mobile-image-changer.js": loadBlob("application/javascript", `/\x2f Drag navigation for swiping between images on mobile. import DragHandler from '/vview/misc/drag-handler.js'; import Bezier2D from '/vview/util/bezier.js'; import FlingVelocity from '/vview/util/fling-velocity.js'; import DirectAnimation from '/vview/actors/direct-animation.js'; import Actor from '/vview/actors/actor.js'; import { helpers } from '/vview/misc/helpers.js'; export default class DragImageChanger extends Actor { constructor({parent}) { super({parent}); this.recentPointerMovement = new FlingVelocity({ samplePeriod: 0.150, }); /\x2f The amount we've dragged. This is relative to the main image, so it doesn't need to /\x2f be adjusted when we add or remove viewers. this.dragDistance = 0; /\x2f A list of viewers that we're dragging between. This always includes the main viewer /\x2f which is owned by the screen. this.viewers = []; this.animations = null; /\x2f Once we reach the left and right edge, this is set to the minimum and maximum value /\x2f of this.dragDistance. this.bounds = [null, null]; this.dragger = new DragHandler({ parent: this, name: "image-changer", element: this.parent.root, confirmDrag: ({event}) => { /\x2f Stop if there's no image, if the screen wasn't able to load one. if(this.mainViewer == null) return false; if(helpers.shouldIgnoreHorizontalDrag(event)) return false; return true; }, ondragstart: (args) => this.ondragstart(args), ondrag: (args) => this.ondrag(args), ondragend: (args) => this.ondragend(args), deferredStart: () => { /\x2f If an animation is running, disable deferring drags, so grabbing the dragger will /\x2f stop the animation. Otherwise, defer drags until the first pointermove (the normal /\x2f behavior). return this.animations == null && this.dragDistance == 0; }, }); } /\x2f Get the distance between one viewer and the next. get viewerDistance() { return this.parent.viewContainer.offsetWidth + this.imageGap; } /\x2f Return the additional space between viewers. get imageGap() { return 25; } /\x2f The main viewer is the one active in the screen. this.dragDistance is relative to /\x2f it, and it's always in this.viewers during drags. get mainViewer() { return this.parent.viewer; } /\x2f The image changed externally or the screen is becoming inactive, so stop any drags and animations. stop() { this.dragger.cancelDrag(); this.cancelAnimation(); /\x2f If a drag was running then cancelDrag will have run ondragend and cleaned up, /\x2f but if we're in the middle of an animation we need to removeViewers ourself. this.removeViewers(); } ondragstart({event}) { /\x2f If we aren't grabbing a running drag, only start if the initial movement was horizontal. if(this.animations == null && this.dragDistance == 0 && Math.abs(event.movementY) > Math.abs(event.movementX)) return false; this.dragDistance = 0; this.recentPointerMovement.reset(); this.bounds = [null, null]; if(this.animations == null) { /\x2f We weren't animating, so this is a new drag. Start the list off with the main viewer. this.viewers = [this.mainViewer]; return true; } /\x2f Another drag started while the previous drag's transition was still happening. /\x2f Stop the animation, and set the drag distance to where the animation was stopped. this.cancelAnimation(); return true; } /\x2f If an animation is running, cancel it. cancelAnimation() { if(!this.animations) return; let animations = this.animations; this.animations = null; /\x2f Pause the animations, and wait until the pause completes. for(let animation of animations) animation.pause(); /\x2f If a drag is active, set drag distance to the X position of the main viewer to match /\x2f the drag to where the animation was. if(this.dragDistance != null && this.mainViewer) { let mainTransform = new DOMMatrix(getComputedStyle(this.mainViewer.root).transform); this.dragDistance = mainTransform.e; /\x2f X translation this.refreshDragPosition(); } /\x2f Remove the animations. for(let animation of animations) animation.cancel(); } ondrag({event, first}) { let x = event.movementX; this.recentPointerMovement.addSample({ x }); /\x2f If we're past the end, apply friction to indicate it. This uses stronger overscroll /\x2f friction to make it distinct from regular image panning overscroll. let overscroll = 1; if(this.bounds[0] != null && this.dragDistance > this.bounds[0]) { let distance = Math.abs(this.bounds[0] - this.dragDistance); overscroll = Math.pow(0.97, distance); } if(this.bounds[1] != null && this.dragDistance < this.bounds[1]) { let distance = Math.abs(this.bounds[1] - this.dragDistance); overscroll = Math.pow(0.97, distance); } x *= overscroll; /\x2f The first pointer input after a touch may be thresholded by the OS trying to filter /\x2f out slight pointer movements that aren't meant to be drags. This causes the very /\x2f first movement to contain a big jump on iOS, causing drags to jump. Count this movement /\x2f towards fling sampling, but skip it for the visual drag. if(!first) this.dragDistance += x; this._addViewersIfNeeded(); this.refreshDragPosition(); } getViewerX(viewerIndex) { /\x2f This offset from the main viewer. Viewers above are negative and below /\x2f are positive. let relativeIdx = viewerIndex - this.mainViewerIndex; let x = this.viewerDistance * relativeIdx; x += this.dragDistance; return x; } /\x2f Update the positions of all viewers during a drag. refreshDragPosition() { for(let idx = 0; idx < this.viewers.length; ++idx) { let viewer = this.viewers[idx]; let x = this.getViewerX(idx); viewer.root.style.transform = \`translateX(\${x}px)\`; viewer.visible = true; } } /\x2f Return the index of the main viewer in this.viewers. get mainViewerIndex() { return this._findViewerIndex(this.mainViewer); } _findViewerIndex(viewer) { let index = this.viewers.indexOf(viewer); if(index == -1) { console.error("Viewer is missing"); return 0; } return index; } /\x2f Add a new viewer if we've dragged far enough to need one. async _addViewersIfNeeded() { let dragThreshold = 5; /\x2f See if we need to add another viewer in either direction. /\x2f /\x2f The right edge of the leftmost viewer, including the gap between images. If this is /\x2f 0, it's just above the screen. let leftViewerEdge = this.getViewerX(-1) + this.viewerDistance; let addForwards = null; if(leftViewerEdge > dragThreshold) addForwards = false; /\x2f The left edge of the rightmost viewer. let rightViewerEdge = this.getViewerX(this.viewers.length) - this.imageGap; if(rightViewerEdge < window.innerWidth - dragThreshold) addForwards = true; /\x2f If the user drags multiple times quickly, the drag target may be past the end. /\x2f Add a viewer for it as soon as it's been dragged to, even though it may be well /\x2f off-screen, so we're able to transition to it. let targetViewerIndex = this.currentDragTarget(); if(targetViewerIndex < 0) addForwards = false; else if(targetViewerIndex >= this.viewers.length) addForwards = true; /\x2f Stop if we're not adding a viewer. if(addForwards == null) return; /\x2f The viewer ID we're adding next to: let neighborViewer = this.viewers[addForwards? this.viewers.length-1:0]; let neighborMediaId = neighborViewer.mediaId; let { mediaId, earlyIllustData, cancelled } = await this._createViewer(addForwards, neighborMediaId); if(cancelled) { /\x2f The viewer list changed while we were loading, or another call to _addViewersIfNeeded /\x2f was made. return; } if(mediaId == null) { /\x2f There's nothing in this direction, so remember that this is the boundary. Once we /\x2f do this, overscroll will activate in this direction. if(addForwards) this.bounds[1] = this.viewerDistance * (this.viewers.length - 1 - this.mainViewerIndex); else this.bounds[0] = this.viewerDistance * (0 - this.mainViewerIndex); return; } let viewer = this.parent.createViewer({ earlyIllustData, mediaId, displayedByDrag: true, }); /\x2f Hide the viewer until after we set the transform, or iOS sometimes flickers it in /\x2f its initial position. viewer.visible = false; /\x2f Insert the new viewer. this.viewers.splice(addForwards? this.viewers.length:0, 0, viewer); /\x2f Set the initial position. this.refreshDragPosition(); } /\x2f Create a new viewer relative to the given media ID, and look up its media info. /\x2f Return { mediaId, mediaInfo }. /\x2f /\x2f If the viewer list changes or another call is made before this completes, discard /\x2f the result and return { cancelled: true }. async _createViewer(addForwards, neighborMediaId) { let viewers = this.viewers; let sentinel = this.addingViewer = new Object(); try { /\x2f Get the next or previous media ID. let mediaId = await this.parent.getNavigation(addForwards, { navigateFromMediaId: neighborMediaId }); if(mediaId == null) return { } let earlyIllustData = await ppixiv.mediaCache.getMediaInfo(mediaId, { full: false }); return { mediaId, earlyIllustData }; } finally { let cancelled = sentinel != this.addingViewer; if(sentinel == this.addingViewer) this.addingViewer = null; /\x2f Cancel if the viewer list changed while we were loading. if(this.viewers !== viewers) cancelled = true; /\x2f If we were cancelled, discard our return value. if(cancelled) return { cancelled: true }; } } removeViewers() { /\x2f Shut down viewers. Leave the main one alone, since it's owned by the screen. for(let viewer of this.viewers) { if(viewer != this.mainViewer) viewer.shutdown(); } this.viewers = []; } /\x2f Get the viewer index that we'd want to go to if the user released the drag now. /\x2f This may be past the end of the current viewer list. currentDragTarget() { /\x2f If the user flung horizontally, move relative to the main viewer. let recentVelocity = this.recentPointerMovement.currentVelocity.x; let threshold = 200; if(Math.abs(recentVelocity) > threshold) { if(recentVelocity > threshold) return this.mainViewerIndex - 1; else if(recentVelocity < -threshold) return this.mainViewerIndex + 1; } /\x2f There hasn't been a fling recently, so land on the viewer which is closest to /\x2f the middle of the screen. If the screen is dragged down several times quickly /\x2f and we're animating to an offscreen main viewer, and the user stops the /\x2f animation in the middle, this stops us on a nearby image instead of continuing /\x2f to where we were going before. let closestViewreIndex = 0; let closestViewerDistance = 999999; for(let idx = 0; idx < this.viewers.length; ++idx) { let x = this.getViewerX(idx); let center = x + window.innerWidth/2; let distance = Math.abs((window.innerWidth / 2) - center); if(distance < closestViewerDistance) { closestViewerDistance = distance; closestViewreIndex = idx; } } return closestViewreIndex; } /\x2f A drag finished. See if we should transition the image or undo. /\x2f /\x2f interactive is true if this is the user releasing it, or false if we're shutting /\x2f down during a drag. cancel is true if this was a cancelled pointer event. async ondragend({interactive, cancel}={}) { let draggedToViewer = null; if(interactive && !cancel) { let targetViewerIndex = this.currentDragTarget(); if(targetViewerIndex >= 0 && targetViewerIndex < this.viewers.length) draggedToViewer = this.viewers[targetViewerIndex]; } /\x2f If we start a fling from this release, this is the velocity we'll try to match. let recentVelocity = this.recentPointerMovement.currentVelocity.x; this.recentPointerMovement.reset(); /\x2f If this isn't interactive, we're just shutting down, so remove viewers without /\x2f animating. if(!interactive) { this.dragDistance = 0; this.cancelAnimation(); this.removeViewers(); return; } /\x2f The image was released interactively. If we're not transitioning to a new /\x2f image, transition back to normal. if(draggedToViewer) { /\x2f Set latestNavigationDirectionDown to true if we're navigating forwards or false /\x2f if we're navigating backwards. This is a hint for speculative loading. let oldMainIndex = this.mainViewerIndex; let newMainIndex = this._findViewerIndex(draggedToViewer); this.parent.latestNavigationDirectionDown = newMainIndex > oldMainIndex; /\x2f The drag was released and we're selecting draggedToViewer. Make it active immediately, /\x2f without waiting for the animation to complete. This lets the UI update quickly, and /\x2f makes it easier to handle quickly dragging multiple times. We keep our viewer list until /\x2f the animation finishes. /\x2f /\x2f Take the main viewer to turn it into a preview. It's in this.viewers, and this prevents /\x2f the screen from shutting it down when we activate the new viewer. this.parent.takeViewer(); /\x2f Make our neighboring viewer primary. this.parent.showImageViewer({ newViewer: draggedToViewer }); /\x2f Update the page URL to point to this viewer. let args = ppixiv.app.getMediaURL(draggedToViewer.mediaId); helpers.navigate(args, { addToHistory: false, sendPopstate: false }); } let duration = 400; let animations = []; let mainViewerIndex = this.mainViewerIndex; for(let idx = 0; idx < this.viewers.length; ++idx) { let viewer = this.viewers[idx]; /\x2f This offset from the main viewer. Viewers above are negative and below /\x2f are positive. let thisIdxd = idx - mainViewerIndex; /\x2f The animation starts at the current translateX. let startX = new DOMMatrix(getComputedStyle(viewer.root).transform).e; /\x2flet startX = this.getViewerX(idx); /\x2f Animate everything to their default positions relative to the main image. let endX = this.viewerDistance * thisIdxd; /\x2f Estimate a curve to match the fling. let { easing } = Bezier2D.findCurveForVelocity({ distance: Math.abs(endX - startX), duration, targetVelocity: Math.abs(recentVelocity), }); /\x2f If we're panning left but the user dragged right (or vice versa), that usually means we /\x2f dragged past the end into overscroll, and all we're doing is moving back in bounds. Ignore /\x2f the drag velocity since it isn't related to our speed. if((endX > startX) != (recentVelocity > 0)) easing = "ease-out"; let animation = new DirectAnimation(new KeyframeEffect(viewer.root, [ { transform: viewer.root.style.transform }, { transform: \`translateX(\${endX}px)\` }, ], { duration, fill: "forwards", easing, })); animation.play(); animations.push(animation); } this.dragDistance = 0; this.animations = animations; let animationsFinished = Promise.all(animations.map((animation) => animation.finished)); try { /\x2f Wait for the animations to complete. await animationsFinished; } catch(e) { /\x2f If this fails, it should be from ondragstart cancelling the animations due to a /\x2f new touch. /\x2f console.error(e); return; } console.assert(this.animations === animations); this.animations = null; for(let animation of animations) { animation.commitStylesIfPossible(); animation.cancel(); } this.removeViewers(); } }; /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/screen-illust/mobile-image-changer.js `), "/vview/screen-illust/mobile-image-dismiss.js": loadBlob("application/javascript", `import WidgetDragger from '/vview/actors/widget-dragger.js'; import Actor from '/vview/actors/actor.js'; import { helpers, FixedDOMRect } from '/vview/misc/helpers.js'; /\x2f This handles dragging up from the top of the screen to return to the search on mobile. export default class MobileImageDismiss extends Actor { constructor({parent}) { super({parent}); this.dragger = new WidgetDragger({ parent: this, name: "drag-to-exit", nodes: [ this.parent.root, this.parent.querySelector(".fade-search"), ], dragNode: this.parent.root, size: () => this._dragDistance, animatedProperty: "--illust-hidden", animatedPropertyInverted: true, /\x2f We're hidden until setActive makes us visible. visible: false, direction: "up", /\x2f up to make visible, up to down duration: () => { return ppixiv.settings.get("animations_enabled")? 250:0; }, size: 500, /\x2f Don't do anything if the screen isn't active. confirmDrag: ({event}) => this.parent._active && ppixiv.mobile, onactive: () => { /\x2f Close the menu bar if it's open when a drag starts. if(this.parent.mobileIllustUi) this.parent.mobileIllustUi.hide(); this._configAnimation(); }, oninactive: () => { if(this.dragger.visible) { /\x2f Scroll the search view to the current image when we're not animating. this.scrollSearchToThumbnail(); } else { /\x2f We're no longer visible. If the screen is still active, complete the navigation /\x2f back to the search screen. If the screen is already inactive then we're animating /\x2f a navigation that has already happened (browser back). if(this.parent._active) { let args = new helpers.args(this.parent.dataSource.searchUrl.toString()); ppixiv.app.navigateFromIllustToSearch(args); } /\x2f See if we want to remove the viewer now that the animation has finished. this.parent.cleanupImage(); } }, }); } get _dragDistance() { return document.documentElement.clientHeight * .25; } _configAnimation() { /\x2f If the view container is hidden, it may have transforms from the previous transition. /\x2f Unset the animation properties so this doesn't affect our calculations here. this.parent.root.style.setProperty("--animation-x", \`0px\`); this.parent.root.style.setProperty("--animation-y", \`0px\`); this.parent.root.style.setProperty("--animation-scale", "1"); /\x2f This gives us the portion of the viewer which actually contains an image. We'll /\x2f transition that region, so empty space is ignored by the transition. If the viewer /\x2f doesn't implement this, just use the view bounds. let viewPosition = this.parent.viewer?.viewPosition; if(viewPosition) { /\x2f Move the view position to where the view actually is on the screen. let { left, top } = this.parent.viewer.root.getBoundingClientRect(); viewPosition.x += left; viewPosition.y += top; } viewPosition ??= this.parent.root.getBoundingClientRect(); /\x2f Try to position the animation to move towards the search thumbnail. let thumbRect = this._animationTargetRect; if(thumbRect) { /\x2f If the thumbnail is offscreen, ignore it. let center_y = thumbRect.top + thumbRect.height/2; if(center_y < 0 || center_y > window.innerHeight) thumbRect = null; } if(thumbRect == null) { /\x2f If we don't know where the thumbnail is, use a rect in the middle of the screen. let width = viewPosition.width * 0.75; let height = viewPosition.height * 0.75; let x = (window.innerWidth - width) / 2; let y = (window.innerHeight - height) / 2; thumbRect = new FixedDOMRect(x, y, x + width, y + height); } let { x, y, width, height } = viewPosition; let scale = Math.max(thumbRect.width / width, thumbRect.height / height); /\x2f Shift the center of the image to 0x0: let animationX = -(x + width/2) * scale; let animationY = -(y + height/2) * scale; /\x2f Align to the center of the thumb. animationX += thumbRect.x + thumbRect.width / 2; animationY += thumbRect.y + thumbRect.height / 2; this.parent.root.style.setProperty("--animation-x", \`\${animationX}px\`); this.parent.root.style.setProperty("--animation-y", \`\${animationY}px\`); this.parent.root.style.setProperty("--animation-scale", scale); } /\x2f Return the rect we'll want to transition towards, if known. get _animationTargetRect() { return ppixiv.app.getRectForMediaId(this.parent._wantedMediaId); } /\x2f The screen was set active or inactive. activate({cause}) { /\x2f Run the show animation if we're not shown, or if we're currently hiding. if(!this.dragger.visible || !this.dragger.isAnimatingToShown) { /\x2f Skip the animation if this is a new page load rather than a transition from /\x2f something else. let transition = cause != "initialization"; this.dragger.show({transition}); /\x2f If we're transitioning scrollSearchToThumbnail will be called when the transition /\x2f finishes. That won't happen if we're not transitioning, so do it now. if(!transition) this.scrollSearchToThumbnail(); } } deactivate() { if(this.dragger.visible) this.dragger.hide(); } get isAnimating() { return this.dragger.isAnimationPlaying; } /\x2f Return a promise that resolves when there's no animation running, or null if /\x2f no animation is active. get waitForAnimationsPromise() { return this.dragger.finished; } /\x2f Scroll the thumbnail onscreen in the search view if the search isn't currently visible. scrollSearchToThumbnail() { if(this.isAnimating || !this.parent.active || this.dragger.position < 1) return; ppixiv.app.scrollSearchToMediaId(this.parent.dataSource, this.parent._wantedMediaId); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/screen-illust/mobile-image-dismiss.js `), "/vview/screen-illust/mobile-ui.js": loadBlob("application/javascript", `/\x2f The image UI for mobile. import Widget from '/vview/widgets/widget.js'; import { BookmarkButtonWidget } from '/vview/widgets/illust-widgets.js'; import MoreOptionsDropdown from '/vview/widgets/more-options-dropdown.js'; import { BookmarkTagListWidget } from '/vview/widgets/bookmark-tag-list.js'; import { AvatarWidget } from '/vview/widgets/user-widgets.js'; import { IllustWidget, GetMediaInfo } from '/vview/widgets/illust-widgets.js'; import { getUrlForMediaId } from '/vview/misc/media-ids.js' import DialogWidget from '/vview/widgets/dialog.js'; import WidgetDragger from '/vview/actors/widget-dragger.js'; import IsolatedTapHandler from '/vview/actors/isolated-tap-handler.js'; import { helpers, ClassFlags, OpenWidgets } from '/vview/misc/helpers.js'; /\x2f The container for the mobile image UI. This just creates and handles displaying /\x2f the tabs. export default class MobileImageUI extends Widget { constructor({ /\x2f This node receives our drag animation property. This goes on the screen instead of /\x2f us, so the video UI can see it too. transitionTarget, ...options }) { super({...options, template: \`
\`}); this.transitionTarget = transitionTarget; this.avatarWidget = new AvatarWidget({ container: this.root.querySelector(".avatar"), clickAction: "author", }); this.root.querySelector(".avatar").hidden = ppixiv.native; /\x2f Get the user ID to load the avatar. this.getMediaInfo = new GetMediaInfo({ parent: this, neededData: "partial", onrefresh: async({mediaInfo}) => { this.avatarWidget.visible = mediaInfo != null; this.avatarWidget.setUserId(mediaInfo?.userId); }, }); this.dragger = new WidgetDragger({ parent: this, name: "menu-dragger", /\x2f Put the --menu-bar-pos property up high, since the video UI also uses it. nodes: [this.transitionTarget], dragNode: this.root.parentNode, size: () => this.querySelector(".menu-bar").offsetHeight, animatedProperty: "--menu-bar-pos", direction: "up", confirmDrag: () => { /\x2f Don't start if MobileImageDismiss is animating, so dragging up during the /\x2f transition back to ScreenIllust doesn't open the menu. if(this.parent.mobileImageDismiss.isAnimating) return false; return true; }, oncancelled: ({otherDragger}) => { if(!this.dragger.visible) return; /\x2f Hide the menu if another dragger starts, so we hide if the image changer, pan/zoom, /\x2f etc. begin. We do it this way and not with a ClickOutsideListener so we don't /\x2f close when a new menu drag starts. this.dragger.hide(); /\x2f Prevent IsolatedTapHandler, so it doesn't trigger from this press and reopen us. IsolatedTapHandler.preventTaps(); }, onbeforeshown: () => this.callVisibilityChanged(), onafterhidden: () => this.callVisibilityChanged(), onactive: () => this.callVisibilityChanged(), oninactive: () => this.callVisibilityChanged(), }); this._mediaId = null; this.querySelector(".button-more").addEventListener("click", (e) => { new MoreOptionsDialog({ mediaId: this._mediaId }); this.dragger.hide(); }); this.querySelector(".button-info").addEventListener("click", (e) => { new MobileIllustInfoDialog({ mediaId: this._mediaId, dataSource: this.dataSource, }); this.dragger.hide(); }); this.buttonBookmark = this.querySelector(".bookmark-button-container"); this.bookmarkButtonWidget = new ImageBookmarkedWidget({ container: this.buttonBookmark }); this.viewManga = this.querySelector(".manga-button-container"); this.viewMangaWidget = new ViewMangaWidget({ container: this.viewManga }); this.buttonBookmark.addEventListener("click", (e) => { new BookmarkTagDialog({ mediaId: this._mediaId }); this.dragger.hide(); }); /\x2f This tells widgets that want to be above us how tall we are. helpers.html.setSizeAsProperty(this.querySelector(".menu-bar"), { target: this.closest(".screen"), heightProperty: "--menu-bar-height", ...this._signal }); this.refresh(); } set mediaId(mediaId) { if(this._mediaId == mediaId) return; /\x2f We'll apply the media ID to our children in refresh(). this._mediaId = mediaId; this.refresh(); } get mediaId() { return this._mediaId; } /\x2f We control our own visibility based on the dragger. get visible() { return this.dragger.visible; } get actuallyVisible() { return this.dragger.visible; } visibilityChanged() { super.visibilityChanged(); /\x2f Hide if our tree becomes hidden. if(!this.visibleRecursively) this.hide(); let visible = this.actuallyVisible; /\x2f Only hide if we're actually not visible, so we're hidden if we're offscreen but /\x2f visible for transitions. this.root.hidden = !visible; helpers.html.setClass(document.documentElement, "illust-menu-visible", visible); /\x2f This enables pointer-events only when the animation is finished. This avoids problems /\x2f with iOS sending clicks to the button when it wasn't pressable when the touch started. helpers.html.setClass(this.root, "fully-visible", visible && !this.dragger.isAnimationPlaying); this.refresh(); } show() { this.dragger.show(); } hide() { this.dragger.hide(); } toggle() { if(this.dragger.visible) this.hide(); else this.show(); } setDataSource(dataSource) { if(this.dataSource == dataSource) return; this.dataSource = dataSource; this.refresh(); } set mediaId(mediaId) { if(this._mediaId == mediaId) return; this._mediaId = mediaId; this.refresh(); } refresh() { super.refresh(); /\x2f Don't refresh while we're hiding, so we don't flash the next page's info while we're /\x2f hiding right after the page is dragged. This shouldn't happen when displaying, since /\x2f our media ID should be set before show() is called. if(this.dragger.isAnimationPlaying) return; this.getMediaInfo.id = this._mediaId; /\x2f Set data-mobile-ui-visible if we're fully visible so other UIs can tell if this UI is /\x2f open. let fullyVisible = this.dragger.position == 1; ClassFlags.get.set("mobile-ui-visible", fullyVisible); /\x2f Add ourself to OpenWidgets if we're visible at all. let visible = this.actuallyVisible; OpenWidgets.singleton.set(this, visible); /\x2f If we're not visible, don't refresh an illust until we are, so we don't trigger /\x2f data loads. Do refresh even if we're hidden if we have no illust to clear /\x2f the previous illust's display even if we're not visible, so it's not visible the /\x2f next time we're displayed. if(!this.visible && this._mediaId != null) return helpers.html.setClass(this.root.querySelector(".button-bookmark"), "enabled", true); /\x2f If we're visible, tell widgets what we're viewing. Don't do this if we're not visible, so /\x2f they don't load data unnecessarily. Don't set these back to null if we're hidden, so they /\x2f don't blank themselves while we're still fading out. if(this.visible) { let mediaId = this._mediaId; this.bookmarkButtonWidget.setMediaId(mediaId); this.viewMangaWidget.setMediaId(mediaId); } } } /\x2f IllustBottomMenuBar's bookmark button. class ImageBookmarkedWidget extends IllustWidget { constructor({ ...options }) { super({ ...options, template: \`
Bookmark
\` }); } get neededData() { return "partial"; } refreshInternal({ mediaInfo }) { let bookmarked = mediaInfo?.bookmarkData != null; let privateBookmark = mediaInfo?.bookmarkData?.private; helpers.html.setClass(this.root, "enabled", mediaInfo != null); helpers.html.setClass(this.root, "bookmarked", bookmarked); helpers.html.setClass(this.root, "public", !privateBookmark); } } class ViewMangaWidget extends IllustWidget { constructor({ ...options }) { super({ ...options, template: \`
\${ helpers.createIcon("ppixiv:thumbnails") } Pages
\` }); this.root.addEventListener("click", (e) => this.onClick()); } get neededData() { return "full"; } refreshInternal({ mediaInfo }) { let seriesId = mediaInfo?.seriesNavData?.seriesId; this.root.dataset.popup = mediaInfo == null? "": seriesId != null? "View series":"View manga pages"; let enabled = seriesId != null || mediaInfo?.pageCount > 1; this.root.hidden = !enabled; this.querySelector(".manga").hidden = seriesId != null; this.querySelector(".series").hidden = seriesId == null; /\x2f Store where we should go on click. if(seriesId != null) { this.navigateArgs = new helpers.args("/", ppixiv.plocation); this.navigateArgs.path = \`/user/\${mediaInfo.userId}/series/\${seriesId}\`; } else if(mediaInfo?.pageCount > 1) this.navigateArgs = getUrlForMediaId(mediaInfo?.mediaId, { manga: true }); else this.navigateArgs = null; } onClick(e) { if(this.navigateArgs) helpers.navigate(this.navigateArgs); } } class BookmarkTagDialog extends DialogWidget { constructor({mediaId, ...options}) { super({...options, dialogClass: "mobile-tag-list", header: "Bookmark illustration", template: \` \`}); this.tagListWidget = new BookmarkTagListWidget({ container: this.root.querySelector(".scroll"), containerPosition: "afterbegin", }); this.publicBookmark = new BookmarkButtonWidget({ container: this.root.querySelector(".public-bookmark"), template: \`
\`, bookmarkType: "public", /\x2f Instead of deleting the bookmark, save tag changes when these bookmark buttons /\x2f are clicked. toggleBookmark: false, /\x2f Close if a bookmark button is clicked. bookmarkTagListWidget: this.tagListWidget, }); this.publicBookmark.addEventListener("bookmarkedited", () => this.visible = false); let privateBookmark = this.root.querySelector(".private-bookmark"); privateBookmark.hidden = ppixiv.native; if(!ppixiv.native) { this.privateBookmark = new BookmarkButtonWidget({ container: privateBookmark, template: \`
\`, bookmarkType: "private", toggleBookmark: false, bookmarkTagListWidget: this.tagListWidget, }); this.privateBookmark.addEventListener("bookmarkedited", () => this.visible = false); } let deleteBookmark = this.root.querySelector(".remove-bookmark"); this.deleteBookmark = new BookmarkButtonWidget({ container: deleteBookmark, template: \`
\${ helpers.createIcon("mat:delete") }
\`, bookmarkType: "delete", bookmarkTagListWidget: this.tagListWidget, }); this.deleteBookmark.addEventListener("bookmarkedited", () => this.visible = false); this.tagListWidget.setMediaId(mediaId); this.publicBookmark.setMediaId(mediaId); this.deleteBookmark.setMediaId(mediaId); if(this.privateBookmark) this.privateBookmark.setMediaId(mediaId); } visibilityChanged() { super.visibilityChanged(); /\x2f Let the tag list know when it's hidden, so it knows to save changes. if(this.tagListWidget) this.tagListWidget.visible = this.actuallyVisible; } } class MoreOptionsDialog extends DialogWidget { constructor({template, mediaId, ...options}) { super({...options, header: "More", classes: ['mobile-illust-ui-dialog'], template: \`
\`}); this.moreOptionsWidget = new MoreOptionsDropdown({ container: this.root.querySelector(".box"), }); this.moreOptionsWidget.setMediaId(mediaId); } /\x2f moreOptionsWidget items can call hide() on us when it's clicked. hide() { this.visible = false; } } class MobileIllustInfoDialog extends DialogWidget { constructor({mediaId, dataSource, ...options}) { super({...options, header: "More", classes: ['mobile-illust-ui-dialog'], template: \`
\`}); this.dataSource = dataSource; this.avatarWidget = new AvatarWidget({ container: this.root.querySelector(".avatar"), mode: "dropdown", }); this.root.querySelector(".avatar").hidden = ppixiv.native; this.getMediaInfo = new GetMediaInfo({ parent: this, id: mediaId, onrefresh: async(info) => this.refreshInternal(info), }); } async refreshInternal({ mediaId, mediaInfo }) { this.root.hidden = mediaInfo == null; if(this.root.hidden) return; this.querySelector(".author").textContent = \`by \${mediaInfo?.userName}\`; this.avatarWidget.setUserId(mediaInfo?.userId); let isLocal = helpers.mediaId.isLocal(mediaId); let tags = isLocal? mediaInfo.bookmarkData?.tags:mediaInfo.tagList; tags ??= []; /\x2f If this is a local image then the tags are bookmark tags, so don't try to get translations. let translatedTags = {}; if(!isLocal) translatedTags = await ppixiv.tagTranslations.getTranslations(tags, "en"); let tagWidget = this.root.querySelector(".bookmark-tags"); helpers.html.removeElements(tagWidget); for(let tag of tags) { let entry = this.createTemplate({name: "tag-entry", html: \` \${ helpers.createIcon("ppixiv:tag", { classes: ["bookmark-tag-icon"] }) } \`}); let translatedTag = tag; if(translatedTags[tag]) translatedTag = translatedTags[tag] entry.href = helpers.getArgsForTagSearch(tag, ppixiv.plocation); entry.querySelector(".tag-name").innerText = translatedTag; tagWidget.appendChild(entry); } let setInfo = (query, text) => { let node = this.root.querySelector(query); node.innerText = text; node.hidden = text == ""; }; /\x2f Add the page count for manga. If the data source is dataSource.vview, show /\x2f the index of the current file if it's loaded all results. let pageCount = mediaInfo.pageCount; let pageText = this.dataSource.getPageTextForMediaId(mediaId); if(pageText == null && pageCount > 1) { let currentPage = this._page; if(currentPage > 0) pageText = \`Page \${currentPage+1}/\${pageCount}\`; else pageText = \`\${pageCount} pages\`; } setInfo(".page-count", pageText ?? ""); this.header = mediaInfo.illustTitle; /\x2f If we're on the first page then we only requested early info, and we can use the dimensions /\x2f on it. Otherwise, get dimensions from mangaPages from illust data. If we're displaying a /\x2f manga post and we don't have illust data yet, we don't have dimensions, so hide it until /\x2f it's loaded. let info = ""; let { width, height } = ppixiv.mediaCache.getImageDimensions(mediaInfo, mediaId); if(width != null && height != null) info += width + "x" + height; setInfo(".image-info-text", info); let secondsOld = (new Date() - new Date(mediaInfo.createDate)) / 1000; let age = helpers.strings.ageToString(secondsOld); this.root.querySelector(".post-age").dataset.popup = helpers.strings.dateToString(mediaInfo.createDate); setInfo(".post-age", age); let elementComment = this.querySelector(".description"); elementComment.hidden = mediaInfo.illustComment == ""; elementComment.innerHTML = mediaInfo.illustComment; helpers.pixiv.fixPixivLinks(elementComment); if(!ppixiv.native) helpers.pixiv.makePixivLinksInternal(elementComment); } setDataSource(dataSource) { if(this.dataSource == dataSource) return; this.dataSource = dataSource; this.refresh(); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/screen-illust/mobile-ui.js `), "/vview/screen-illust/screen-illust.js": loadBlob("application/javascript", `/\x2f The main controller for viewing images. /\x2f /\x2f This handles creating and navigating between viewers. import Screen from '/vview/screen.js'; import DesktopImageInfo from '/vview/screen-illust/desktop-image-info.js'; import MobileImageChanger from '/vview/screen-illust/mobile-image-changer.js'; import MobileImageDismiss from '/vview/screen-illust/mobile-image-dismiss.js'; import MobileUI from '/vview/screen-illust/mobile-ui.js'; import DesktopViewerImages from '/vview/viewer/images/desktop-viewer-images.js'; import MobileViewerImages from '/vview/viewer/images/mobile-viewer-images.js'; import ViewerVideo from '/vview/viewer/video/viewer-video.js'; import ViewerUgoira from '/vview/viewer/video/viewer-ugoira.js'; import ViewerError from '/vview/viewer/viewer-error.js'; import ImagePreloader from '/vview/misc/image-preloader.js'; import IsolatedTapHandler from '/vview/actors/isolated-tap-handler.js'; import LocalAPI from '/vview/misc/local-api.js'; import { HideMouseCursorOnIdle } from '/vview/misc/hide-mouse-cursor-on-idle.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f The main UI. This handles creating the viewers and the global UI. export default class ScreenIllust extends Screen { get screenType() { return "illust"; } constructor(options) { super({...options, template: \`
\${ helpers.createIcon("mat:translate") }
\`}); this.currentMediaId = null; this.latestNavigationDirectionDown = true; /\x2f Create a UI box and put it in its container. let uiContainer = this.root.querySelector(".ui"); if(!ppixiv.mobile) this.desktopUi = new DesktopImageInfo({ container: uiContainer }); /\x2f Make sure the hover UI isn't shown on mobile. if(ppixiv.mobile) uiContainer.hidden = true; ppixiv.userCache.addEventListener("usermodified", this.refreshUi, { signal: this.shutdownSignal }); ppixiv.mediaCache.addEventListener("mediamodified", this.refreshUi, { signal: this.shutdownSignal }); ppixiv.settings.addEventListener("recent-bookmark-tags", this.refreshUi, { signal: this.shutdownSignal }); this.viewContainer = this.root.querySelector(".view-container"); /\x2f Remove the "flash" class when the page change indicator's animation finishes. let pageChangeIndicator = this.root.querySelector(".page-change-indicator"); pageChangeIndicator.addEventListener("animationend", (e) => { pageChangeIndicator.classList.remove("flash"); }); /\x2f Desktop UI: if(!ppixiv.mobile) { /\x2f Fullscreen on double-click. this.viewContainer.addEventListener("dblclick", () => { helpers.toggleFullscreen(); }); new HideMouseCursorOnIdle(this.root.querySelector(".mouse-hidden-box")); this.root.addEventListener("wheel", this.onwheel, { passive: false }); } /\x2f Mobile UI: if(ppixiv.mobile) { /\x2f Create this before mobileIllustUi so its drag handler is registered first. /\x2f This makes image change drags take priority over opening the menu. this.mobileImageChanger = new MobileImageChanger({ parent: this }); this.mobileIllustUi = new MobileUI({ container: this.root, transitionTarget: this.root, }); /\x2f Toggle zoom on double-tap. this.root.addEventListener("dblclick", (e) => this.viewer.toggleZoom(e), this._signal); new IsolatedTapHandler({ parent: this, node: this.viewContainer, callback: (e) => { /\x2f Show or hide the menu on isolated taps. Note that most of the time, hiding /\x2f will happen in mobileIllustUi's oncancelled handler, when a press triggers /\x2f another scroller (usually TouchScroller). But, we also handle it here as a /\x2f fallback in case that doesn't happen, such as if we're on a video. this.mobileIllustUi.toggle(); }, }); } /\x2f This handles transitioning between this and the search view. this.mobileImageDismiss = new MobileImageDismiss({ parent: this }); this.deactivate(); } setDataSource(dataSource) { if(dataSource == this.dataSource) return; if(this.dataSource != null) { this.dataSource.removeEventListener("updated", this.dataSourceUpdated); this.dataSource = null; } this.dataSource = dataSource; if(this.desktopUi) this.desktopUi.dataSource = dataSource; if(this.dataSource != null) { this.dataSource.addEventListener("updated", this.dataSourceUpdated); this.refreshUi(); } } async activate({ mediaId, restoreHistory, cause }) { let wasActive = this._active; this._active = true; super.activate(); /\x2f If we have a viewer, tell it if we're active. if(this.viewer != null) this.viewer.active = true; /\x2f If we have a drag handler for mobile, cancel any drag or animation in progress /\x2f if the image changes externally or if we're deactivated. if(this.mobileImageChanger) this.mobileImageChanger.stop(); await this.showImage(mediaId, { restoreHistory, initial: !wasActive }); /\x2f Tell the dragger to transition us in. if(this.mobileImageDismiss) this.mobileImageDismiss.activate({ cause }); } deactivate() { super.deactivate(); this._active = false; /\x2f If we have a viewer, tell it if we're active. if(this.viewer != null) this.viewer.active = false; /\x2f If we have a drag handler for mobile, cancel any drag or animation in progress /\x2f if the image changes externally or if we're deactivated. if(this.mobileImageChanger) this.mobileImageChanger.stop(); this.cancelAsyncNavigation(); if(this.mobileIllustUi) { this.mobileIllustUi.mediaId = null; this.mobileIllustUi.setDataSource(null); } /\x2f Tell the dragger to transition us out. if(this.mobileImageDismiss) this.mobileImageDismiss.deactivate(); this.cleanupImage(); /\x2f We leave editing on when navigating between images, but turn it off when we exit to /\x2f the search. ppixiv.settings.set("image_editing_mode", null); } /\x2f Remove the viewer if we no longer want to be displaying it. cleanupImage() { if(this._active) return; /\x2f Don't remove the viewer if it's still being shown in the exit animation. if(this.mobileImageDismiss?.isAnimating) return; this.removeViewer(); this._wantedMediaId = null; this.currentMediaId = null; this.refreshUi(); /\x2f Tell the preloader that we're not displaying an image anymore. This prevents the next /\x2f image displayed from triggering speculative loading, which we don't want to do when /\x2f clicking an image in the thumbnail view. ImagePreloader.singleton.setCurrentImage(null); ImagePreloader.singleton.setSpeculativeImage(null); /\x2f If remote quick view is active, cancel it if we leave the image. if(ppixiv.settings.get("linked_tabs_enabled")) { ppixiv.sendImage.sendMessage({ message: "send-image", action: "cancel", to: ppixiv.settings.get("linked_tabs", []), }); } } /\x2f Create a viewer for mediaId and begin loading it asynchronously. createViewer({ mediaId, earlyIllustData, ...options }={}) { let viewerClass; let isMuted = earlyIllustData && this.shouldHideMutedImage(earlyIllustData).isMuted; let isError = earlyIllustData == null; if(isMuted) { viewerClass = ViewerError; } else if(isError) { viewerClass = ViewerError; let error = ppixiv.mediaCache.getMediaLoadError(mediaId); error ??= "Unknown error"; options = { ...options, error }; } else if(earlyIllustData.illustType == 2) viewerClass = ViewerUgoira; else if(earlyIllustData.illustType == "video") viewerClass = ViewerVideo; else viewerClass = ppixiv.mobile? MobileViewerImages:DesktopViewerImages; let newViewer = new viewerClass({ mediaId, container: this.viewContainer, slideshow: helpers.args.location.hash.get("slideshow"), waitForTransitions: () => { return this.mobileImageDismiss?.waitForAnimationsPromise; }, onnextimage: async (finishedViewer) => { if(!this._active) return { }; /\x2f Ignore this if this isn't the active viewer. This can happen if we advance a slideshow /\x2f right as the user navigated to a different image, especially with mobile transitions. if(finishedViewer != this.viewer) { console.log("onnextimage from viewer that isn't active"); return { }; } /\x2f The viewer wants to go to the next image, normally during slideshows. let manga = ppixiv.settings.get("slideshow_skips_manga")? "skip-to-first":"normal"; return await this.navigateToNext(1, { flashAtEnd: false, manga }); }, ...options, }); newViewer.load(); return newViewer; } /\x2f Show a media ID. async showImage(mediaId, { restoreHistory=false, initial=false }={}) { console.assert(mediaId != null); /\x2f If we previously set a pending navigation, this navigation overrides it. this.cancelAsyncNavigation(); /\x2f Remember that this is the image we want to be displaying. Do this before going /\x2f async, so everything knows what we're trying to display immediately. this._wantedMediaId = mediaId; if(await this.loadFirstImage(mediaId)) return; /\x2f Get very basic illust info. This is enough to tell which viewer to use, how /\x2f many pages it has, and whether it's muted. This will always complete immediately /\x2f if we're coming from a search or anywhere else that will already have this info, /\x2f but it can block if we're loading from scratch. let earlyIllustData = await ppixiv.mediaCache.getMediaInfo(mediaId, { full: false }); /\x2f If we were deactivated while waiting for image info or the image we want to show has changed, stop. if(!this.active || this._wantedMediaId != mediaId) { console.log("showImage: illust ID or page changed while async, stopping"); return; } /\x2f Make sure the dragger isn't active, since changing main viewers while a drag is active /\x2f would cause confusing behavior. if(this.mobileImageChanger) this.mobileImageChanger.stop(); /\x2f If we weren't given a viewer to use, create one. let newViewer = this.createViewer({ earlyIllustData, mediaId, restoreHistory, }); this.showImageViewer({ newViewer, initial }); } /\x2f Show a viewer. /\x2f /\x2f If initial is first, this is the first image we're displaying after becoming visible, /\x2f usually from clicking a search result. If it's false, we were already active and are /\x2f just changing images. showImageViewer({ newViewer=null, initial=false }={}) { if(newViewer == this.viewer) return; helpers.html.setClass(document.body, "force-ui", window.debugShowUi); let mediaId = newViewer.mediaId; /\x2f Dismiss any message when changing images. if(this.currentMediaId != mediaId) ppixiv.message.hide(); this._wantedMediaId = mediaId; this.currentMediaId = mediaId; /\x2f This should always be available, because the caller always looks up media info /\x2f in order to create the viewer, which means we don't have to go async here. If /\x2f this returns null, it should always mean we're viewing an image's error page. let earlyIllustData = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }); if(this.active) helpers.setTitleAndIcon(earlyIllustData); /\x2f If the image has the ドット絵 tag, enable nearest neighbor filtering. helpers.html.setClass(document.body, "dot", helpers.pixiv.tagsContainDot(earlyIllustData?.tagList)); /\x2f If linked tabs are active, send this image. if(ppixiv.settings.get("linked_tabs_enabled")) ppixiv.sendImage.send_image(mediaId, ppixiv.settings.get("linked_tabs", []), "temp-view"); /\x2f If this is a local image, refresh each view so we'll see changes quickly. This is async, /\x2f so in the usual case where nothing changes it doesn't cause a delay. Don't do this for /\x2f Pixiv images. if(helpers.mediaId.isLocal(mediaId)) ppixiv.mediaCache.refreshMediaInfo(mediaId); /\x2f Tell the preloader about the current image. ImagePreloader.singleton.setCurrentImage(mediaId); /\x2f Speculatively load the next image, which is what we'll show if you press page down, so /\x2f advancing through images is smoother. /\x2f /\x2f If we're not local, don't do this when showing the first image, since the most common /\x2f case is simply viewing a single image and then backing out to the search, so this avoids /\x2f doing extra loads every time you load a single illustration. if(!initial || helpers.mediaId.isLocal(mediaId)) { /\x2f getNavigation may block to load more search results. Run this async without /\x2f waiting for it. (async() => { let newMediaId = await this.getNavigation(this.latestNavigationDirectionDown); /\x2f Let ImagePreloader handle speculative loading. If newMediaId is null, /\x2f we're telling it that we don't need to load anything. ImagePreloader.singleton.setSpeculativeImage(newMediaId); })(); } this.refreshUi(); /\x2f If we're not animating so we know the search page isn't visible, try to scroll the /\x2f search page to the image we're viewing, so it's ready if we start a transition to it. if(this.mobileImageDismiss) this.mobileImageDismiss.scrollSearchToThumbnail(); /\x2f If we already have an old viewer, then we loaded an image, and then navigated again before /\x2f the new image was displayed. Discard the new image and keep the old one, since it's what's /\x2f being displayed. if(this.oldViewer && this.viewer) { this.viewer.shutdown(); this.viewer = null; } else this.oldViewer = this.viewer; this.viewer = newViewer; let oldViewer = this.oldViewer; /\x2f If we already had a viewer, hide the new one until the new one is ready to be displayed. /\x2f We'll make it visible below at the same time the old viewer is removed, so we don't show /\x2f both at the same time. if(this.oldViewer) this.viewer.visible = false; this.viewer.ready.finally(() => { /\x2f The new viewer is displaying an image, so we can remove the old viewer now. /\x2f /\x2f If this isn't the main viewer anymore, another one was created and replaced this one /\x2f (the old viewer check above), so don't do anything. if(this.viewer !== newViewer || oldViewer !== this.oldViewer) return; this.viewer.visible = true; if(this.oldViewer) { this.oldViewer.shutdown(); this.oldViewer = null; } }); this.viewer.active = this._active; /\x2f Refresh the UI now that we have a new viewer. this.refreshUi(); } /\x2f Take the current viewer out of the screen. It'll still be active and in the document. /\x2f This is used by DragImageChanger to change the current viewer into a preview viewer. takeViewer() { let viewer = this.viewer; this.viewer = null; return viewer; } /\x2f If we're loading "*", it's a placeholder saying to view the first search result. /\x2f This allows viewing shuffled results. This can be a Pixiv illust ID of *, or /\x2f a local ID with a filename of *. Load the initial data source page if it's not /\x2f already loaded, and navigate to the first result. async loadFirstImage(mediaId) { if(helpers.mediaId.isLocal(mediaId)) { let args = helpers.args.location; LocalAPI.getArgsForId(mediaId, args); if(args.hash.get("file") != "*") return false; } else if(helpers.mediaId.parse(mediaId).id != "*") return false; /\x2f This will load results if needed, skip folders so we only pick images, and return /\x2f the first ID. let newMediaId = await this.dataSource.getOrLoadNeighboringMediaId(null, true); if(newMediaId == null) { ppixiv.message.show("Couldn't find an image to view"); return true; } ppixiv.app.showMediaId(newMediaId, { addToHistory: false, }); return true; } /\x2f Return true if we're allowing a muted image to be displayed, because the user /\x2f clicked to override it in the mute view. get viewMuted() { return helpers.args.location.hash.get("view-muted") == "1"; } shouldHideMutedImage(earlyIllustData) { let mutedTag = ppixiv.muting.anyTagMuted(earlyIllustData.tagList); let mutedUser = ppixiv.muting.isUserIdMuted(earlyIllustData.userId); if(this.viewMuted || (!mutedTag && !mutedUser)) return { isMuted: false }; return { isMuted: true, mutedTag, mutedUser }; } /\x2f Remove the old viewer, if any. removeViewer() { if(this.viewer != null) { this.viewer.shutdown(); this.viewer = null; } if(this.oldViewer != null) { this.oldViewer.shutdown(); this.oldViewer = null; } } /\x2f If we started navigating to a new image and were delayed to load data (either to load /\x2f the image or to load a new page), cancel it and stay where we are. cancelAsyncNavigation() { /\x2f If we previously set a pending navigation, this navigation overrides it. if(this.pendingNavigation == null) return; console.info("Cancelling async navigation"); this.pendingNavigation = null; } dataSourceUpdated = () => { this.refreshUi(); } get active() { return this._active; } /\x2f Refresh the UI for the current image. refreshUi = (e) => { /\x2f Don't refresh if the thumbnail view is active. We're not visible, and we'll just /\x2f step over its page title, etc. if(!this._active) return; /\x2f Tell the UI which page is being viewed. if(this.desktopUi) this.desktopUi.mediaId = this.currentMediaId; if(this.mobileIllustUi) { this.mobileIllustUi.mediaId = this.currentMediaId; this.mobileIllustUi.setDataSource(this.dataSource); } if(this.desktopUi) this.desktopUi.refresh(); } onwheel = (e) => { if(!this._active) return; let down = e.deltaY > 0; this.navigateToNext(down, { manga: e.shiftKey? "skip-to-first":"normal" }); } get displayedMediaId() { return this._wantedMediaId; } handleKeydown(e) { /\x2f Let the viewer handle the input first. if(this.viewer && this.viewer.onkeydown) { this.viewer.onkeydown(e); if(e.defaultPrevented) return; } if(this.desktopUi) this.desktopUi.handleKeydown(e); if(e.defaultPrevented) return; if(e.ctrlKey || e.altKey || e.metaKey) return; switch(e.key) { case "ArrowLeft": case "ArrowUp": case "PageUp": e.preventDefault(); e.stopPropagation(); this.navigateToNext(false, { manga: e.shiftKey? "skip-to-first":"normal" }); break; case "ArrowRight": case "ArrowDown": case "PageDown": e.preventDefault(); e.stopPropagation(); this.navigateToNext(true, { manga: e.shiftKey? "skip-to-first":"normal" }); break; } } /\x2f Get the media ID and page navigating down (or up) will go to. /\x2f /\x2f This may trigger loading the next page of search results, if we've reached the end. async getNavigation(down, { navigateFromMediaId=null, manga="normal", loop=false }={}) { /\x2f Check if we're just changing pages within the same manga post. /\x2f If we have a target media ID, move relative to it. Otherwise, move relative to the /\x2f displayed image. This way, if we navigate repeatedly before a previous navigation /\x2f finishes, we'll keep moving rather than waiting for each navigation to complete. navigateFromMediaId ??= this._wantedMediaId; navigateFromMediaId ??= this.currentMediaId; /\x2f Get the next (or previous) illustration after the current one. if(!loop) return await this.dataSource.getOrLoadNeighboringMediaId(navigateFromMediaId, down, { manga }); let mediaId = await this.dataSource.getOrLoadNeighboringMediaIdWithLoop(navigateFromMediaId, down, { manga }); /\x2f If we only have one image, don't loop. We won't actually navigate so things /\x2f don't quite work, since navigating to the same media ID won't trigger a navigation. if(mediaId == navigateFromMediaId) { console.log("Not looping since we only have one media ID"); return null; } return mediaId; } /\x2f Navigate to the next or previous image. /\x2f /\x2f manga is a manga skip mode. See IllustIdList.getNeighboringMediaId. async navigateToNext(down, { manga="normal", flashAtEnd=true }={}) { /\x2f Loop if we're in slideshow mode, otherwise stop when we reach the end. let loop = helpers.args.location.hash.get("slideshow") != null; /\x2f If we're viewing an error page, always skip manga pages. if(manga == "normal" && this.viewer instanceof ViewerError) manga = "skip-past"; /\x2f Remember whether we're navigating forwards or backwards, for preloading. this.latestNavigationDirectionDown = down; this.cancelAsyncNavigation(); let pendingNavigation = this.pendingNavigation = new Object(); /\x2f See if we should change the manga page. This may block if it needs to load /\x2f the next page of search results. let newMediaId = await this.getNavigation(down, { manga, loop }); /\x2f If we didn't get a page, we're at the end of the search results. Flash the /\x2f indicator to show we've reached the end and stop. if(newMediaId == null) { console.log("Reached the end of the list"); if(flashAtEnd) this.flashEndIndicator(down, "last-image"); return { reachedEnd: true }; } /\x2f If this.pendingNavigation is no longer the same as pendingNavigation, we navigated since /\x2f we requested this load and this navigation is stale, so stop. if(this.pendingNavigation != pendingNavigation) { console.error("Aborting stale navigation"); return { stale: true }; } this.pendingNavigation = null; /\x2f Go to the new illustration. ppixiv.app.showMediaId(newMediaId); return { mediaId: newMediaId }; } flashEndIndicator(down, icon) { let indicator = this.root.querySelector(".page-change-indicator"); indicator.dataset.icon = icon; indicator.dataset.side = down? "right":"left"; indicator.classList.remove("flash"); /\x2f Call getAnimations() so the animation is removed immediately: indicator.getAnimations(); indicator.classList.add("flash"); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/screen-illust/screen-illust.js `), "/vview/screen-search/mobile-menu-bar.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; import CreateSearchMenu from '/vview/screen-search/search-menu.js'; import { SettingsDialog } from '/vview/widgets/settings-widgets.js'; import { DataSource_BookmarksBase } from '/vview/sites/pixiv/data-sources/bookmarks.js'; import DialogWidget from '/vview/widgets/dialog.js'; import LocalAPI from '/vview/misc/local-api.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f The bottom navigation bar for mobile, showing the current search and exposing a smaller /\x2f action bar when open. This vaguely follows the design language of iOS Safari. export default class MobileSearchUI extends Widget { constructor(options) { super({ ...options, template: \`
\${ helpers.createIcon("mat:arrow_back_ios_new") }
\${ helpers.createIcon("refresh") }
\${ helpers.createIcon("wallpaper") }
\${ helpers.createIcon("settings") }
\`}); this.root.querySelector(".refresh-search-button").addEventListener("click", () => this.parent.refreshSearch()); this.root.querySelector(".preferences-button").addEventListener("click", (e) => new SettingsDialog()); this.root.querySelector(".slideshow").addEventListener("click", (e) => helpers.navigate(ppixiv.app.slideshowURL)); this.root.querySelector(".menu").addEventListener("click", (e) => new MobileEditSearchDialog()); this.root.querySelector(".back-button").addEventListener("click", () => { if(ppixiv.native) { if(this.parent.displayedMediaId == null) return; let parentFolderId = LocalAPI.getParentFolder(this.parent.displayedMediaId); let args = helpers.args.location; LocalAPI.getArgsForId(parentFolderId, args); helpers.navigate(args); } else if(ppixiv.phistory.permanent) { ppixiv.phistory.back(); } }); } applyVisibility() { helpers.html.setClass(this.root, "shown", this._visible); } refreshUi() { /\x2f The back button navigate to parent locally, otherwise it's browser back if we're in /\x2f permanent history mode. let backButton = this.root.querySelector(".back-button"); let showBackButton; if(ppixiv.native) showBackButton = LocalAPI.getParentFolder(this.parent.displayedMediaId) != null; else if(ppixiv.phistory.permanent) showBackButton = ppixiv.phistory.length > 1; helpers.html.setClass(backButton, "disabled", !showBackButton); } } /\x2f This dialog shows the search filters that are in the header box on desktop. class MobileEditSearchDialog extends DialogWidget { constructor({...options}={}) { super({...options, dialogClass: "edit-search-dialog", header: "Search", template: \`
\` }); /\x2f Create the menu items. This is the same as the dropdown list for desktop. let optionBox = this.root.querySelector(".search-selection"); CreateSearchMenu(optionBox); this.root.addEventListener("click", (e) => { let a = e.target.closest("A"); if(a == null) return; /\x2f Hide the dialog when any of the menu links are clicked. this.visible = false; /\x2f Don't actually navigate for clicks on rows with the disable-clicks class, since they /\x2f don't go anywhere. They just refer to the search we're already on. if(a.classList.contains("disable-clicks")) e.preventDefault(); }); this.searchUrl = helpers.args.location; this.refresh(); } get activeRow() { /\x2f The active row is the one who would load a data source of the same class as the current one. let currentDataSource = this.dataSource; for(let button of this.root.querySelectorAll(".navigation-button")) { let url = new URL(button.href); let dataSourceClass = ppixiv.site.getDataSourceForUrl(url); if(currentDataSource instanceof dataSourceClass) return button; /\x2f Hack: the bookmarks row corresponds to multiple subclasses. All of them should /\x2f map back to the bookmarks row. if(currentDataSource instanceof DataSource_BookmarksBase && dataSourceClass.prototype instanceof DataSource_BookmarksBase) return button; } throw new Error("Couldn't match data source for", currentDataSource.__proto__); } refresh() { let activeRow = this.activeRow; for(let button of this.root.querySelectorAll(".navigation-button")) helpers.html.setClass(button, "selected", button == activeRow); /\x2f Show this row if it's hidden. Some rows are only displayed while they're in use. activeRow.widget.visible = true; /\x2f If this is the artist row, set the title based on the artist name. if(activeRow.classList.contains("artist-row")) { let title = this.dataSource.uiInfo.mobileTitle; if(title) activeRow.querySelector(".label").innerText = title; } this._recreateUi(); } /\x2f We always show the primary data source. get dataSource() { return ppixiv.app.currentDataSource; } _recreateUi() { /\x2f Create the UI. let position = this.activeRow; let row = position.closest(".box-link-row"); if(row) position = row; } /\x2f Tell DialogWidget not to close us on popstate. It'll still close us if the screen changes. get _closeOnPopstate() { return false; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/screen-search/mobile-menu-bar.js `), "/vview/screen-search/screen-search.js": loadBlob("application/javascript", `import Screen from '/vview/screen.js'; import DesktopSearchUI from '/vview/screen-search/search-ui-desktop.js'; import SearchUIMobile from '/vview/screen-search/search-ui-mobile.js'; import MobileMenuBar from '/vview/screen-search/mobile-menu-bar.js'; import ScrollListener from '/vview/actors/scroll-listener.js'; import LocalNavigationTreeWidget from '/vview/widgets/folder-tree.js'; import SearchView from '/vview/screen-search/search-view.js'; import LocalAPI from '/vview/misc/local-api.js'; import HoverWithDelay from '/vview/actors/hover-with-delay.js'; import { helpers, OpenWidgets } from '/vview/misc/helpers.js'; /\x2f The search UI. export default class ScreenSearch extends Screen { get screenType() { return "search"; } constructor(options) { super({...options, template: \`
\`}); ppixiv.userCache.addEventListener("usermodified", this.refreshUi, { signal: this.shutdownSignal }); this.searchView = new SearchView({ container: this.root.querySelector(".thumbnail-container-box"), }); /\x2f Add the top search UI if we're on desktop. if(!ppixiv.mobile) { let searchDesktopUiBox = this.root.querySelector(".search-desktop-ui"); searchDesktopUiBox.hidden = false; this.desktopSearchUi = new DesktopSearchUI({ container: searchDesktopUiBox, }); /\x2f Add a slight delay before hiding the UI. This allows opening the UI by swiping past the top /\x2f of the window, without it disappearing as soon as the mouse leaves the window. This doesn't /\x2f affect opening the UI. new HoverWithDelay({ parent: this, element: searchDesktopUiBox, enterDelay: 0, exitDelay: 0.25 }); /\x2f Set --ui-box-height to the container's height, which is used by the hover style. let resize = new ResizeObserver(() => { searchDesktopUiBox.style.setProperty('--ui-box-height', \`\${searchDesktopUiBox.offsetHeight}px\`); }).observe(searchDesktopUiBox); this.shutdownSignal.addEventListener("abort", () => resize.disconnect()); /\x2f The ui-on-hover class enables the hover style if it's enabled. let refreshUiOnHover = () => helpers.html.setClass(searchDesktopUiBox, "ui-on-hover", ppixiv.settings.get("ui-on-hover") && !ppixiv.mobile); ppixiv.settings.addEventListener("ui-on-hover", refreshUiOnHover, { signal: this.shutdownSignal }); refreshUiOnHover(); } if(ppixiv.mobile) { this.mobileSearchUi = new SearchUIMobile({ container: this.root.querySelector(".search-mobile-ui"), }); let navigationBarContainer = this.root.querySelector(".mobile-navigation-bar-container"); this.mobileMenuBar = new MobileMenuBar({ container: navigationBarContainer, }); /\x2f Set the height on the nav bar and title for transitions to use. helpers.html.setSizeAsProperty(this.mobileSearchUi.root, { ...this._signal, heightProperty: "--title-height", target: this.root, }); helpers.html.setSizeAsProperty(this.mobileMenuBar.root, { ...this._signal, heightProperty: "--nav-bar-height", target: this.root, }); let scroller = this.querySelector(".search-results"); this.scrollListener = new ScrollListener({ scroller, parent: this, onchange: () => this._refreshMenuBarVisible(), stickyUiNode: this.mobileSearchUi.root, }); OpenWidgets.singleton.addEventListener("changed", () => this._refreshMenuBarVisible(), this._signal); this._refreshMenuBarVisible(); } /\x2f Zoom the thumbnails on ctrl-mousewheel: this.root.addEventListener("wheel", (e) => { if(!e.ctrlKey) return; e.preventDefault(); e.stopImmediatePropagation(); ppixiv.settings.adjustZoom("thumbnail-size", e.deltaY > 0); }, { passive: false }); this.root.addEventListener("keydown", (e) => { let zoom = helpers.isZoomHotkey(e); if(zoom != null) { e.preventDefault(); e.stopImmediatePropagation(); ppixiv.settings.adjustZoom("thumbnail-size", zoom < 0); } }); /\x2f If the local API is enabled and tags aren't restricted, set up the directory tree sidebar. /\x2f /\x2f We don't currently show the local navigation panel on mobile. The UI isn't set up for /\x2f it, and it causes thumbnails to flicker while scrolling for some reason. if(LocalAPI.isEnabled() && !LocalAPI.localInfo.bookmark_tag_searches_only && !ppixiv.mobile) { let localNavigationBox = this.root.querySelector(".local-navigation-box"); /\x2f False if the user has hidden the navigation tree. Default to false on mobile, since /\x2f it takes up a lot of screen space. Also default to false if we were initially opened /\x2f as a similar image search. this._localNavigationVisible = !ppixiv.mobile && ppixiv.plocation.pathname != "/similar"; this._localNavigationTree = new LocalNavigationTreeWidget({ container: localNavigationBox, }); /\x2f Hack: if the local API isn't enabled, hide the local navigation box completely. This shouldn't /\x2f be needed since it'll hide itself, but this prevents it from flashing onscreen and animating /\x2f away when the page loads. That'll still happen if you have the local API enabled and you're on /\x2f a Pixiv page, but this avoids the visual glitch for most users. I'm not sure how to fix this /\x2f cleanly. localNavigationBox.hidden = false; } } get active() { return this._active; } deactivate() { super.deactivate(); if(!this._active) return; this._active = false; this.searchView.deactivate(); } async activate() { super.activate(); this._active = true; this.refreshUi(); await this.searchView.activate(); } /\x2f Return the media ID we'll try to scroll to if the given state is loaded. getTargetMediaId(args) { let scroll = args.state.scroll; let targetMediaId = scroll?.scrollPosition?.mediaId; return targetMediaId; } getRectForMediaId(mediaId) { return this.searchView.getRectForMediaId(mediaId); } setDataSource(dataSource, { targetMediaId }) { /\x2f Remove listeners from the old data source. if(this.dataSource != null) this.dataSource.removeEventListener("updated", this.dataSourceUpdated); this.dataSource = dataSource; this.searchView.setDataSource(dataSource, { targetMediaId }); if(this.desktopSearchUi) this.desktopSearchUi.setDataSource(dataSource); if(this.mobileSearchUi) this.mobileSearchUi.setDataSource(dataSource); if(this.dataSource == null) { this.refreshUi(); return; } /\x2f Listen to the data source loading new pages, so we can refresh the list. this.dataSource.addEventListener("updated", this.dataSourceUpdated); this.refreshUi(); }; dataSourceUpdated = () => { this.refreshUi(); } refreshSearch() { ppixiv.app.setCurrentDataSource({ refresh: true, startAtBeginning: true }); } refreshSearchFromPage() { ppixiv.app.setCurrentDataSource({ refresh: true, startAtBeginning: false }); } refreshUi = () => { if(this.desktopSearchUi) this.desktopSearchUi.refreshUi(); if(this.mobileSearchUi) this.mobileSearchUi.refreshUi(); if(this.mobileMenuBar) this.mobileMenuBar.refreshUi(); this.dataSource.setPageIcon(); if(this.active) helpers.setPageTitle(this.dataSource.pageTitle || "Loading..."); /\x2f Refresh whether we're showing the local navigation widget and toggle button. helpers.html.setDataSet(this.root.dataset, "showNavigation", this.canShowLocalNavigation && this._localNavigationVisible); }; _refreshMenuBarVisible() { /\x2f Hide the UI when scrolling down, and also hide the menu bar if a dialog is /\x2f open. Do allow the menu bar to be opened while not active, so we set the /\x2f correct initial state. let shown = !this.scrollListener.scrolledForwards; this.mobileMenuBar.visible = shown && OpenWidgets.singleton.empty; this.mobileSearchUi.visible = shown; } get canShowLocalNavigation() { return this.dataSource?.isVView && !LocalAPI?.localInfo?.bookmark_tag_searches_only; } /\x2f Return the user ID we're viewing, or null if we're not viewing anything specific to a user. get viewingUserId() { if(this.dataSource == null) return null; return this.dataSource.viewingUserId; } /\x2f If the data source has an associated artist, return the "user:ID" for the user, so /\x2f when we navigate back to an earlier search, pulseThumbnail will know which user to /\x2f flash. get displayedMediaId() { if(this.dataSource == null) return super.displayedMediaId; let mediaId = this.dataSource.uiInfo.mediaId; if(mediaId != null) return mediaId; return super.displayedMediaId; } async handleKeydown(e) { if(e.repeat) return; if(this.dataSource.name == "vview" || this.dataSource.name == "vview-search") { /\x2f Pressing ^F while on the local search focuses the search box. if(e.code == "KeyF" && e.ctrlKey) { this.root.querySelector(".local-tag-search-box input").focus(); e.preventDefault(); e.stopPropagation(); } /\x2f Pressing ^V while on the local search pastes into the search box. We don't do /\x2f this for other searches since this is the only one I find myself wanting to do /\x2f often. if(e.code == "KeyV" && e.ctrlKey) { let text = await navigator.clipboard.readText(); let input = this.root.querySelector(".local-tag-search-box input"); input.value = text; LocalAPI.navigateToTagSearch(text, {addToHistory: false}); } } } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/screen-search/screen-search.js `), "/vview/screen-search/search-menu.js": loadBlob("application/javascript", `/\x2f This creates the entries for selecting a search mode. This is shared by the /\x2f desktop dropdown menu and the mobile popup. import { MenuOptionButton, MenuOptionRow } from '/vview/widgets/menu-option.js'; function getMainSearchMenuOptions() { if(ppixiv.native) return [ { label: "Files", icon: "search", url: \`/#/\` }, { label: "Similar Images", icon: "search", url: \`/similar#/\`, visible: false, classes: ["disable-clicks"] }, ]; let options = [ /\x2f This is a dummy for when we're viewing an artist on mobile. It can't be selected directly, it's /\x2f only made visible when an artist is being viewed already. { label: "Artist", icon: "face", url: "/users/1#ppixiv", visible: false, classes: ["artist-row", "disable-clicks"] }, /\x2f This weird URL is to work around Pixiv encoding their URLs in a silly way: we have /\x2f to do this to set "artworks" without setting a tag. The content type should be a /\x2f query parameter, putting it in the path doesn't make any sense. { label: "Search works", icon: "search", url: \`/tags/\x2fartworks#ppixiv\` }, { label: "New works by following", icon: "photo_library", url: "/bookmark_new_illust.php#ppixiv" }, { label: "New works by everyone", icon: "groups", url: "/new_illust.php#ppixiv" }, ]; if(ppixiv.mobile) { /\x2f On mobile, just show a single bookmarks and follows item. options = [ ...options, { label: "Bookmarks", icon: "favorite", url: \`/users/\${ppixiv.pixivInfo.userId}/bookmarks/artworks#ppixiv\` }, { label: "Followed users", icon: "visibility", url: \`/users/\${ppixiv.pixivInfo.userId}/following#ppixiv\` }, ]; } else { options = [ ...options, [ { label: "Bookmarks", icon: "favorite", url: \`/users/\${ppixiv.pixivInfo.userId}/bookmarks/artworks#ppixiv\` }, { label: "all", url: \`/users/\${ppixiv.pixivInfo.userId}/bookmarks/artworks#ppixiv\` }, { label: "Public", url: \`/users/\${ppixiv.pixivInfo.userId}/bookmarks/artworks#ppixiv?show-all=0\` }, { label: "Private", url: \`/users/\${ppixiv.pixivInfo.userId}/bookmarks/artworks?rest=hide#ppixiv?show-all=0\` }, ], [ { label: "Followed users", icon: "visibility", url: \`/users/\${ppixiv.pixivInfo.userId}/following#ppixiv\` }, { label: "Public", url: \`/users/\${ppixiv.pixivInfo.userId}/following#ppixiv\` }, { label: "Private", url: \`/users/\${ppixiv.pixivInfo.userId}/following?rest=hide#ppixiv\` }, ] ]; } options = [ ...options, { label: "Rankings", icon: "auto_awesome" /* who names this stuff? */, url: "/ranking.php#ppixiv" }, { label: "Recommended works", icon: "ppixiv:suggestions", url: "/discovery#ppixiv" }, { label: "Recommended users", icon: "ppixiv:suggestions", url: "/discovery/users#ppixiv" }, { label: "Completed requests", icon: "request_page", url: "/request/complete/illust#ppixiv" }, { label: "Users", icon: "search", url: "/search_user.php#ppixiv" }, ]; return options; } export default function CreateSearchMenu(container) { let options = getMainSearchMenuOptions(); let createOption = ({classes=[], ...options}) => { let button = new MenuOptionButton({ classes: [...classes, "navigation-button"], ...options }) return button; }; for(let option of options) { if(Array.isArray(option)) { let row = new MenuOptionRow({ container, }); let first = true; for(let suboption of option) { if(suboption == null) continue; createOption({ ...suboption, container: row.root, }); if(first) { first = false; let div = document.createElement("div"); div.style.flex = "1"; row.root.appendChild(div); } } } else createOption({...option, container}); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/screen-search/search-menu.js `), "/vview/screen-search/search-ui-desktop.js": loadBlob("application/javascript", `/\x2f The main desktop search UI. import Widget from '/vview/widgets/widget.js'; import { AvatarWidget } from '/vview/widgets/user-widgets.js'; import { SettingsDialog } from '/vview/widgets/settings-widgets.js'; import { DropdownMenuOpener } from '/vview/widgets/dropdown.js'; import CreateSearchMenu from '/vview/screen-search/search-menu.js'; import LocalAPI from '/vview/misc/local-api.js'; import { helpers } from '/vview/misc/helpers.js'; export default class DesktopSearchUI extends Widget { constructor(options) { super({ ...options, template: \`
\${ helpers.createIcon("ppixiv:pixiv") } \${ helpers.createIcon("wallpaper") }
\` }); /\x2f Create the search menu dropdown. new DropdownMenuOpener({ button: this.root.querySelector(".main-search-menu-button"), createDropdown: ({...options}) => { let dropdown = this.bookmarkTagsDropdown = new Widget({ ...options, template: \`
\`, }); CreateSearchMenu(dropdown.root); return dropdown; }, }); this.root.querySelector(".refresh-search-from-page-button").addEventListener("click", () => this.parent.refreshSearchFromPage()); this.root.querySelector(".expand-manga-posts").addEventListener("click", (e) => { this.parent.searchView.toggleExpandingMediaIdsByDefault(); }); this.root.querySelector(".refresh-search-button").addEventListener("click", () => this.parent.refreshSearch()); this.toggleLocalNavigationButton = this.root.querySelector(".toggle-local-navigation-button"); this.toggleLocalNavigationButton.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); this.parent._localNavigationVisible = !this.parent._localNavigationVisible; this.parent.refreshUi(); }); this.root.querySelector(".preferences-button").addEventListener("click", (e) => new SettingsDialog()); /\x2f Refresh the "Refresh search from page" tooltip if the page in the URL changes. Use statechange /\x2f rather than popstate for this, so it responds to all URL changes. window.addEventListener("pp:statechange", (e) => this.refreshRefreshSearchFromPage(), { signal: this.shutdownSignal }); this.avatarWidget = new AvatarWidget({ container: this.querySelector(".avatar-container"), /\x2f Disable the avatar widget unless the data source enables it. visible: false, }); this.imageForSuggestions = this.querySelector(".image-for-suggestions"); } setDataSource(dataSource) { if(this.dataSource == dataSource) return; /\x2f Remove any previous data source's UI. if(this.currentDataSourceUi) { this.currentDataSourceUi.shutdown(); this.currentDataSourceUi = null; } this.dataSource = dataSource; this.avatarWidget.setUserId(null); this.avatarWidget.visible = false; this.imageForSuggestions.hidden = true; if(dataSource == null) return; /\x2f Create the new data source's UI. if(this.dataSource.ui) { let dataSourceUiContainer = this.root.querySelector(".data-source-ui"); this.currentDataSourceUi = new this.dataSource.ui({ dataSource: this.dataSource, container: dataSourceUiContainer, }); } } updateFromSettings = () => { this.refreshExpandMangaPostsButton(); } refreshUi() { this.root.querySelector(".refresh-search-from-page-button").hidden = true; /\x2f!this.dataSource?.supportsStartPage; if(this.dataSource) { let { userId, imageUrl, imageLinkUrl } = this.dataSource.uiInfo; this.imageForSuggestions.hidden = imageUrl == null; this.imageForSuggestions.href = imageLinkUrl ?? "#"; let img = this.imageForSuggestions.querySelector(".image-for-suggestions > img"); img.src = imageUrl ?? helpers.other.blankImage; this.avatarWidget.visible = userId != null; this.avatarWidget.setUserId(userId); } let elementTitle = this.root.querySelector(".search-title"); elementTitle.hidden = this.dataSource?.getDisplayingText == null; if(this.dataSource?.getDisplayingText != null) { let text = this.dataSource.getDisplayingText(); elementTitle.replaceChildren(text); } if(this.toggleLocalNavigationButton) { this.toggleLocalNavigationButton.hidden = this.parent._localNavigationTree == null || !this.parent.canShowLocalNavigation; this.toggleLocalNavigationButton.querySelector(".font-icon").innerText = this.parent._localNavigationVisible? "keyboard_double_arrow_left":"keyboard_double_arrow_right"; } this.refreshSlideshowButton(); this.refreshExpandMangaPostsButton(); this.refreshRefreshSearchFromPage(); } /\x2f Refresh the slideshow button. refreshSlideshowButton() { let node = this.root.querySelector("A.slideshow"); node.href = ppixiv.app.slideshowURL.url; } /\x2f Refresh the highlight for the "expand all posts" button. refreshExpandMangaPostsButton() { let enabled = this.parent.searchView.mediaIdsExpandedByDefault; let button = this.root.querySelector(".expand-manga-posts"); button.dataset.popup = enabled? "Collapse manga posts":"Expand manga posts"; button.querySelector(".font-icon").innerText = enabled? "close_fullscreen":"open_in_full"; /\x2f Hide the button if the data source can never return manga posts to be expanded, or /\x2f if it's the manga page itself which always expands. button.hidden = !this.dataSource?.allowExpandingMangaPages; } refreshRefreshSearchFromPage() { if(this.dataSource == null) return; /\x2f Refresh the "refresh from page #" button popup. This is updated by searchView /\x2f as the user scrolls. let startPage = this.dataSource.getStartPage(helpers.args.location); this.root.querySelector(".refresh-search-from-page-button").dataset.popup = \`Refresh search from page \${startPage}\`; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/screen-search/search-ui-desktop.js `), "/vview/screen-search/search-ui-mobile.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; import { AvatarWidget } from '/vview/widgets/user-widgets.js'; import { helpers } from '/vview/misc/helpers.js'; export default class SearchUIMobile extends Widget { constructor({...options}={}) { super({ ...options, template: \`
\` }); this.avatarWidget = new AvatarWidget({ container: this.querySelector(".avatar-container"), /\x2f Disable the avatar widget unless the data source enables it. visible: false, }); } setDataSource(dataSource) { if(this._currentDataSourceUi) { this._currentDataSourceUi.shutdown(); this._currentDataSourceUi = null; } this.dataSource = dataSource; this.avatarWidget.setUserId(null); this.avatarWidget.visible = false; if(dataSource == null) return; /\x2f Create the new data source's UI. if(this.dataSource?.ui) { this._currentDataSourceUi = new this.dataSource.ui({ dataSource: this.dataSource, container: this.querySelector(".data-source-ui"), }); } } refreshUi() { if(this.dataSource) { let { userId } = this.dataSource.uiInfo; this.avatarWidget.visible = userId != null; this.avatarWidget.setUserId(userId); } let elementTitle = this.querySelector(".search-title"); elementTitle.hidden = this.dataSource?.getDisplayingText == null; if(this.dataSource?.getDisplayingText != null) { let text = this.dataSource?.getDisplayingText(); elementTitle.replaceChildren(text); } } applyVisibility() { helpers.html.setClass(this.root, "shown", this._visible); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/screen-search/search-ui-mobile.js `), "/vview/screen-search/search-view.js": loadBlob("application/javascript", `/\x2f The main thumbnail grid view. import Widget from '/vview/widgets/widget.js'; import { MenuOptionsThumbnailSizeSlider } from '/vview/widgets/menu-option.js'; import { getUrlForMediaId } from '/vview/misc/media-ids.js' import PointerListener from '/vview/actors/pointer-listener.js'; import StopAnimationAfter from '/vview/actors/stop-animation-after.js'; import LocalAPI from '/vview/misc/local-api.js'; import { helpers, GuardedRunner } from '/vview/misc/helpers.js'; /\x2f This is the logic for SearchView's grid display. class ThumbnailGrid { constructor({container}) { this.container = container; this.rows = []; this.sizingStyle = null; } clear() { for(let row of this.rows) row.remove(); this.rows = []; } /\x2f Add a thumbnail to the first or last row, adding a new row if it's full. addThumbToRow(node, {atEnd}) { /\x2f Get the row to add to. let row = this.getRow({atEnd}); row.insertAdjacentElement(atEnd? "beforeend":"afterbegin", node); /\x2f Re-align the row with the new thumb. this.alignRow(row); /\x2f If the thumb fit on the row, stop here. The row can still have more thumbs added /\x2f to it. let resultWidth = (row.children.length-1) * this.sizingStyle.padding; for(let thumb of row.children) resultWidth += thumb.currentWidth; resultWidth = Math.round(resultWidth); if(resultWidth <= this.sizingStyle.containerWidth) return row; /\x2f If this is the only thumb in this row, it should always fit. If something goes wrong /\x2f and it doesn't, leave it alone. if(row.children.length == 1) { console.error("Single thumbnail didn't scale to fit:", row); return; } /\x2f Adding another thumb to it caused it to overflow, so this row is full. Remove the /\x2f thumb from the overfilled row, re-align the row and put the thumb on a new one. node.remove(); this.alignRow(row); let newRow = this.createRow({atEnd}); newRow.insertAdjacentElement(atEnd? "beforeend":"afterbegin", node); return newRow; } /\x2f Return a row at the beginning or end, creating a row if needed. getRow({atEnd=true}={}) { /\x2f Get the first or last row. let row = atEnd? this.rows[this.rows.length-1]:this.rows[0]; if(row) return row; else return this.createRow({atEnd}); } /\x2f Create a new row at the beginning or end. createRow({atEnd=true}={}) { /\x2f Create a new row. let row = document.realCreateElement("div"); row.className = "row"; if(atEnd) { this.container.insertAdjacentElement("beforeend", row); this.rows.push(row); } else { this.container.insertAdjacentElement("afterbegin", row); this.rows.splice(0, 0, row); } return row; } getAverageHeightOfRow(row) { if(row.children.length == 0) return 0; /\x2f Get the average height of thumbs on this row. We'll expand thumbs vertically to this height. let totalHeight = 0; for(let thumb of row.children) totalHeight += thumb.origHeight; return totalHeight / row.children.length; } /\x2f Once a row is full and won't have items added to it, finalize it to optimize space usage. alignRow(row) { for(let thumb of row.children) { thumb.currentWidth = thumb.origWidth; thumb.currentHeight = thumb.origHeight; } /\x2f Only adjust the size when in aspect mode, not for square thumbs. if(this.sizingStyle.thumbnailStyle != "aspect") { this.applySizes(row); return; } /\x2f Scale each thumb to the average height of the row, so all thumbs on the row have /\x2f the same height. let averageHeight = this.getAverageHeightOfRow(row); for(let thumb of row.children) { let ratio = averageHeight / thumb.currentHeight; thumb.currentHeight *= ratio; thumb.currentWidth *= ratio; } /\x2f Now try to scale the whole row to fit horizontally. let rowWidth = 0; for(let thumb of row.children) rowWidth += thumb.currentWidth; /\x2f Start with a scale that will exactly fit the view horizontally. let containerWidth = this.sizingStyle.containerWidth - (row.children.length-1) * this.sizingStyle.padding; let scaleX = containerWidth / rowWidth; /\x2f Clamp the amount we'll scale by, so we don't scale incomplete rows up endlessly trying to /\x2f fill the row. let maxAllowedHeight = this.sizingStyle.thumbHeight * 2; scaleX = Math.min(scaleX, maxAllowedHeight / averageHeight); /\x2f If the row has more than one thumb, never scale down. Overflowing horizontally is what /\x2f triggers wrapping onto a new row, and scaling down would make us try to fit if(row.children.length > 1) scaleX = Math.max(scaleX, 1); let scaleY = scaleX; for(let thumb of row.children) { thumb.currentWidth *= scaleX; thumb.currentHeight *= scaleY; } this.applySizes(row); } applySizes(row) { /\x2f Tell the row its height for content-intrinsic-size. All thumbs on a row always have the /\x2f same height. let rowHeight = row.children[0]?.currentHeight ?? 128; row.style.setProperty("--row-height", \`\${rowHeight}px\`); for(let thumb of row.children) { thumb.style.setProperty("--thumb-width", \`\${thumb.currentWidth}px\`); thumb.style.setProperty("--thumb-height", \`\${thumb.currentHeight}px\`); } } } export default class SearchView extends Widget { constructor({...options}) { super({...options, template: \`
\`}); /\x2f The node that scrolls to show thumbs. this.scrollContainer = this.root.closest(".scroll-container"); this.thumbnailBox = this.root.querySelector(".thumbnails"); this._setDataSourceRunner = new GuardedRunner(this._signal); this._loadPageRunner = new GuardedRunner(this._signal); this.grid = new ThumbnailGrid({ container: this.thumbnailBox }); this.artistHeader = this.querySelector(".artist-header"); /\x2f A dictionary of thumbs in the view, in the same order. This makes iterating /\x2f existing thumbs faster than iterating the nodes. this.thumbs = {}; /\x2f A map of media IDs that the user has manually expanded or collapsed. this.expandedMediaIds = new Map(); /\x2f This caches the results of isMediaIdExpanded. this._mediaIdExpandedCache = null; let resizeObserver = new ResizeObserver(() => this.refreshImages({cause: "resize"})); resizeObserver.observe(this.scrollContainer); resizeObserver.observe(this.thumbnailBox); /\x2f The scroll position may not make sense when if scroller changes size (eg. the window was resized /\x2f or we changed orientations). Override it and restore from the latest scroll position that we /\x2f committed to history. new ResizeObserver(() => { let args = helpers.args.location; if(args.state.scroll) this.restoreScrollPosition(args.state.scroll?.scrollPosition); }).observe(this.scrollContainer); /\x2f When a bookmark is modified, refresh the heart icon. ppixiv.mediaCache.addEventListener("mediamodified", (e) => this.refreshThumbnail(e.mediaId), this._signal); /\x2f Call thumbImageLoadFinished when a thumbnail image finishes loading. this.root.addEventListener("load", (e) => { if(e.target.classList.contains("thumb")) this.thumbImageLoadFinished(e.target.closest(".thumbnail-box"), { cause: "onload" }); }, { capture: true } ); this.scrollContainer.addEventListener("scroll", (e) => this.scheduleStoreScrollPosition(), { passive: true }); this.thumbnailBox.addEventListener("click", (e) => this.thumbnailClick(e)); /\x2f As an optimization, start loading image info on mousedown. We don't navigate until click, /\x2f but this lets us start loading image info a bit earlier. this.thumbnailBox.addEventListener("mousedown", async (e) => { if(e.button != 0) return; let a = e.target.closest("a.thumbnail-link"); if(a == null) return; if(a.dataset.mediaId == null) return; /\x2f Only do this for illustrations. let {type} = helpers.mediaId.parse(a.dataset.mediaId); if(type != "illust") return; await ppixiv.mediaCache.getMediaInfo(a.dataset.mediaId); }, { capture: true }); /\x2f Handle quick view. new PointerListener({ element: this.thumbnailBox, buttonMask: 0b1, callback: (e) => { if(!e.pressed) return; let a = e.target.closest("A"); if(a == null) return; if(!ppixiv.settings.get("quick_view")) return; /\x2f Activating on press would probably break navigation on touchpads, so only do /\x2f this for mouse events. if(e.pointerType != "mouse") return; let { mediaId } = ppixiv.app.getMediaIdAtElement(e.target); if(mediaId == null) return; /\x2f Don't stopPropagation. We want the illustration view to see the press too. e.preventDefault(); /\x2f e.stopImmediatePropagation(); ppixiv.app.showMediaId(mediaId, { addToHistory: true }); }, }); /\x2f Create IntersectionObservers for thumbs that are fully onscreen and nearly onscreen. this.intersectionObservers = []; this.intersectionObservers.push(new IntersectionObserver((entries) => { for(let entry of entries) helpers.html.setDataSet(entry.target.dataset, "nearby", entry.isIntersecting); this.refreshImages({cause: "nearby-observer"}); /\x2f If the last thumbnail is now nearby, see if we need to load more search results. this.loadDataSourcePage(); }, { root: this.scrollContainer, /\x2f This margin determines how far in advance we load the next page of results. /\x2f /\x2f On mobile, allow this to be larger so we're less likely to interrupt scrolling. rootMargin: ppixiv.mobile? "400%":"150%", })); ppixiv.settings.addEventListener("thumbnail-size", () => this.updateFromSettings(), this._signal); ppixiv.settings.addEventListener("disable_thumbnail_zooming", () => this.updateFromSettings(), this._signal); ppixiv.settings.addEventListener("disable_thumbnail_panning", () => this.updateFromSettings(), this._signal); ppixiv.settings.addEventListener("expand_manga_thumbnails", () => this.updateFromSettings(), this._signal); ppixiv.settings.addEventListener("thumbnail_style", () => this.updateFromSettings(), this._signal); ppixiv.settings.addEventListener("pixiv_cdn", () => this.updateFromSettings(), this._signal); ppixiv.muting.addEventListener("mutes-changed", () => this.refreshAfterMuteChange(), this._signal); this.updateFromSettings(); } updateFromSettings() { this.refreshExpandedThumbAll(); this.loadExpandedMediaIds(); /\x2f in case expand_manga_thumbnails has changed this.refreshImages({cause: "settings"}); let disableThumbnailZooming = ppixiv.settings.get("disable_thumbnail_zooming") || ppixiv.mobile; if(ppixiv.settings.get("thumbnail_style") == "aspect") disableThumbnailZooming = true; helpers.html.setClass(document.body, "disable-thumbnail-zooming", disableThumbnailZooming); } /\x2f Return the thumbnail container for mediaId. /\x2f /\x2f If mediaId is a manga page and fallbackOnPage1 is true, return page 1 if the exact page /\x2f doesn't exist. getThumbnailForMediaId(mediaId, { fallbackOnPage1=false}={}) { if(this.thumbs[mediaId] != null) return this.thumbs[mediaId]; if(fallbackOnPage1) { /\x2f See if page 1 is available instead. let page1MediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); if(page1MediaId != mediaId && this.thumbs[page1MediaId] != null) return this.thumbs[page1MediaId]; } return null; } /\x2f Return the first thumb that's fully onscreen. getFirstFullyOnscreenThumb() { /\x2f Find the first row near the top-left of the screen. This is used to save and /\x2f restore scroll, so if there's no row exactly overlapping the top-left, prefer /\x2f one below it rather than above it. This doesn't use IntersectionObserver because /\x2f it's async and sometimes doesn't update between resizes, causing the scroll position /\x2f to be lost. let screenTop = this.scrollContainer.scrollTop + this.scrollContainer.offsetHeight/4; let centerRow = null; let bestDistance = 999999; for(let row of this.grid.rows) { let rowTop = row.offsetTop; let distance = Math.abs(rowTop - screenTop); if(distance < Math.abs(bestDistance)) { bestDistance = distance; centerRow = row; } } if(centerRow) return centerRow.firstElementChild; return null; } /\x2f Change the data source. If targetMediaId is specified, it's the media ID we'd like to /\x2f scroll to if possible. setDataSource(dataSource, { targetMediaId }={}) { return this._setDataSourceRunner.call(this._setDataSource.bind(this), { dataSource, targetMediaId }); } async _setDataSource({ dataSource, targetMediaId, signal }={}) { /\x2f console.log("Showing search and scrolling to media ID:", targetMediaId); if(dataSource != this.dataSource) { /\x2f Remove listeners from the old data source. if(this.dataSource != null) this.dataSource.removeEventListener("updated", this.dataSourceUpdated); this._clearThumbs(); this._mediaIdExpandedCache = null; this.dataSource = dataSource; /\x2f Listen to the data source loading new pages, so we can refresh the list. this.dataSource.addEventListener("updated", this.dataSourceUpdated); /\x2f Set the header now if it's already known. this.refreshHeader(); } this.loadExpandedMediaIds(); /\x2f Load the initial page if we haven't yet. await this.loadDataSourcePage({ cause: "initialization" }); signal.throwIfAborted(); /\x2f If we weren't given a media ID to scroll to, see if we have a scroll position to restore. /\x2f If so, tell refreshImages that we want it to be included. let args = helpers.args.location; let scrollMediaId = args.state.scroll?.scrollPosition?.mediaId; /\x2f Create the initial thumbnails. this.refreshImages({ cause: "initial", targetMediaId: targetMediaId ?? scrollMediaId, }); /\x2f If a media ID to display was given, try to scroll to it. Otherwise try to restore the /\x2f previous scroll position around scrollMediaId. if(targetMediaId != null) this.scrollToMediaId(targetMediaId); else if(!this.restoreScrollPosition(args.state.scroll?.scrollPosition)) this.scrollContainer.scrollTop = 0; } loadDataSourcePage({cause="thumbnails"}={}) { /\x2f Guard this against multiple concurrent calls. if(this._loadPageRunner.isRunning) return this._loadPageRunner.promise; return this._loadPageRunner.call(this._loadDataSourcePageInner.bind(this), { cause }); } /\x2f Start loading a data source page if needed. async _loadDataSourcePageInner({cause="thumbnails", signal}={}) { /\x2f We'll only load the next or previous page if we have a thumbnail displayed. let loadPage = this._dataSourcePageToLoad; if(loadPage == null) return; /\x2f Hide "no results" if it's shown while we load data. let noResults = this.root.querySelector(".no-results"); noResults.hidden = true; await this.dataSource.loadPage(loadPage, { cause }); /\x2f Refresh the view with any new data. Skip this if we're in the middle of setDataSource, /\x2f since it wants to make the first refreshImages call. if(!this._setDataSourceRunner.isRunning) this.refreshImages({cause: "data-source-updated"}); signal.throwIfAborted(); /\x2f If we have no IDs and nothing is loading, the data source is empty (no results). if(this.dataSource?.hasNoResults) noResults.hidden = false; /\x2f See if there's another page we want to load. This is async, since the current /\x2f loadDataSourcePage call should complete as soon as we've loaded a single page. (async() => { /\x2f Delay briefly as a sanity check. await helpers.other.sleep(100); this.loadDataSourcePage(); })(); } /\x2f Return the next data source page we want to load. get _dataSourcePageToLoad() { /\x2f We load pages when the last thumbs on the previous page are loaded, but the first /\x2f time through there's no previous page to reach the end of. Always make sure the /\x2f first page is loaded (usually page 1). if(this.dataSource && !this.dataSource.isPageLoadedOrLoading(this.dataSource.initialPage)) return this.dataSource.initialPage; /\x2f After the first page, don't load anything if there are no thumbs. This avoids uncontrolled /\x2f loading: if we start on page 1000 and there's nothing there, we don't want to try loading /\x2f 999, 998, 997 endlessly looking for content. The only thing that triggers more loads is /\x2f a previously loaded thumbnail coming nearby. let thumbs = this.getLoadedThumbs(); if(thumbs.length == 0) return null; /\x2f Load the next page when the last nearby thumbnail (set by the "nearby" IntersectionObserver) /\x2f is the last thumbnail in the list. let lastThumb = thumbs[thumbs.length-1]; if(lastThumb.dataset.nearby) { let loadPage = parseInt(lastThumb.dataset.searchPage) + 1; if(this.dataSource.canLoadPage(loadPage) && !this.dataSource.isPageLoadedOrLoading(loadPage)) return loadPage; } /\x2f Likewise, load the previous page when the first nearby thumbnail is the first thumbnail /\x2f in the list. let firstThumb = thumbs[0]; if(firstThumb.dataset.nearby) { let loadPage = parseInt(firstThumb.dataset.searchPage) - 1; if(!this.dataSource.isPageLoadedOrLoading(loadPage)) return loadPage; } return null; } /\x2f Activate the view, waiting for the current data source to be displayed if needed. async activate() { this._active = true; /\x2f If nothing's focused, focus the search so keyboard navigation works. Don't do this if /\x2f we already have focus, so we don't steal focus from things like the tag search dropdown /\x2f and cause them to be closed. let focus = document.querySelector(":focus"); if(focus == null) this.scrollContainer.focus(); /\x2f Wait until the load started by the most recent call to setDataSource finishes. await this._setDataSourceRunner.promise; } deactivate() { if(!this._active) return; this._active = false; this.stopPulsingThumbnail(); } /\x2f Schedule storing the scroll position, resetting the timer if it's already running. scheduleStoreScrollPosition() { if(this.scrollPositionTimer != -1) { realClearTimeout(this.scrollPositionTimer); this.scrollPositionTimer = -1; } this.scrollPositionTimer = realSetTimeout(() => { this.storeScrollPosition(); }, 100); } /\x2f Save the current scroll position so it can be restored from history, and update the search /\x2f page number. storeScrollPosition() { /\x2f Don't do this if we're in the middle of setDataSource. if(this._setDataSourceRunner.isRunning) return; let args = helpers.args.location; if(this.dataSource?.supportsStartPage) { /\x2f If the data source supports a start page, update the page number in the URL. let firstThumb = this.getFirstFullyOnscreenThumb(); if(firstThumb?.dataset?.searchPage != null) this.dataSource.setStartPage(args, firstThumb.dataset.searchPage); } args.state.scroll = { scrollPosition: this.saveScrollPosition(), }; helpers.navigate(args, { addToHistory: false, cause: "viewing-page", sendPopstate: false }); } /\x2f This is called when the data source has more results. dataSourceUpdated = () => { this.refreshHeader(); } /\x2f Return all media IDs currently loaded in the data source, and the page /\x2f each one is on. getDataSourceMediaIds() { let allMediaIds = []; let mediaIdPages = {}; if(this.dataSource == null) return { allMediaIds, mediaIdPages }; let idList = this.dataSource.idList; let minPage = idList.getLowestLoadedPage(); let maxPage = idList.getHighestLoadedPage(); for(let page = minPage; page <= maxPage; ++page) { let mediaIdsOnPage = idList.mediaIdsByPage.get(page); console.assert(mediaIdsOnPage != null); for(let mediaId of mediaIdsOnPage) { /\x2f Add expanded manga pages. let mediaIdsOnPage = this._getExpandedPages(mediaId); if(mediaIdsOnPage != null) { for(let pageMediaId of mediaIdsOnPage) { allMediaIds.push(pageMediaId); mediaIdPages[pageMediaId] = page; } continue; } allMediaIds.push(mediaId); mediaIdPages[mediaId] = page; } } /\x2f Sanity check: there should never be any duplicate media IDs from the data source. /\x2f Refuse to continue if there are duplicates, since it'll break our logic badly and /\x2f can cause infinite loops. This is always a bug. if(allMediaIds.length != (new Set(allMediaIds)).size) throw Error("Duplicate media IDs"); return { allMediaIds, mediaIdPages }; } /\x2f If mediaId is an expanded multi-page post, return the pages. Otherwise, return null. _getExpandedPages(mediaId) { if(!this.isMediaIdExpanded(mediaId)) return null; let info = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }); if(info == null || info.pageCount <= 1) return null; let results = []; let { type, id } = helpers.mediaId.parse(mediaId); for(let mangaPage = 0; mangaPage < info.pageCount; ++mangaPage) { let pageMediaId = helpers.mediaId.encodeMediaId({type, id, page: mangaPage}); results.push(pageMediaId); } return results; } /\x2f Make a list of media IDs that we want loaded. This has a few inputs: /\x2f /\x2f - The thumbnails that are already loaded, if any. /\x2f - A media ID that we want to have loaded. If we're coming back from viewing an image /\x2f and it's in the search results, we always want that image loaded so we can scroll to /\x2f it. /\x2f - The thumbnails that are near the scroll position (nearby thumbs). These should always /\x2f be loaded. /\x2f /\x2f Try to keep thumbnails that are already loaded in the list, since there's no performance /\x2f benefit to unloading thumbs. Creating thumbs can be expensive if we're creating thousands of /\x2f them, but once they're created, content-visibility keeps things fast. /\x2f /\x2f If targetMediaId is set and it's in the search results, always include it in the results, /\x2f extending the list to include it. If targetMediaId is set and we also have thumbs already /\x2f loaded, we'll extend the range to include both. If this would result in too many images /\x2f being added at once, we'll remove previously loaded thumbs so targetMediaId takes priority. /\x2f /\x2f If we have no nearby thumbs and no ID to force load, it's an initial load, so we'll just /\x2f start at the beginning. /\x2f /\x2f The result is always a contiguous subset of media IDs from the data source. getMediaIdsToDisplay({ allMediaIds, targetMediaId, }) { if(allMediaIds.length == 0) return { startIdx: 0, endIdx: 0 }; let startIdx = 0, endIdx = 0; /\x2f If we have a specific media ID to display and it's not already loaded, ignore what we /\x2f have loaded and start around it instead. let targetMediaIdIdx = allMediaIds.indexOf(targetMediaId); if(targetMediaId && this.thumbs[targetMediaId] == null && targetMediaIdIdx != -1) { startIdx = targetMediaIdIdx; endIdx = targetMediaIdIdx; } else { /\x2f Figure out the range of allMediaIds that we want to have loaded. startIdx = 999999; endIdx = 0; /\x2f Start the range with thumbs that are already loaded, if any. let [firstLoadedMediaId, lastLoadedMediaId] = this.getLoadedMediaIds(); let firstLoadedMediaIdIdx = allMediaIds.indexOf(firstLoadedMediaId); let lastLoadedMediaIdIdx = allMediaIds.indexOf(lastLoadedMediaId); if(firstLoadedMediaIdIdx != -1 && lastLoadedMediaIdIdx != -1) { startIdx = firstLoadedMediaIdIdx; endIdx = lastLoadedMediaIdIdx; } else { /\x2f Otherwise, start at the beginning. startIdx = 0; endIdx = 0; } /\x2f If the last loaded image is nearby, we've scrolled near the end of what's loaded, so add /\x2f another chunk of images to the list. /\x2f /\x2f The chunk size is the number of thumbs we'll create at a time. /\x2f /\x2f Note that this doesn't determine when we'll load another page of data from the server. The /\x2f "nearby" IntersectionObserver threshold controls that. It does trigger media info loads /\x2f if they weren't supplied by the data source (this happens with DataSsource_VView if we're /\x2f using /api/ids). let chunkSizeForwards = 25; let [firstNearbyMediaId, lastNearbyMediaId] = this.getNearbyMediaIds(); let lastNearbyMediaIdIdx = allMediaIds.indexOf(lastNearbyMediaId); if(lastNearbyMediaIdIdx != -1 && lastNearbyMediaIdIdx == lastLoadedMediaIdIdx) endIdx += chunkSizeForwards; /\x2f Similarly, if the first loaded image is nearby, we should load another chunk upwards. /\x2f /\x2f Use a larger chunk size when extending backwards on iOS. Adding to the start of the /\x2f scroller breaks smooth scrolling (is there any way to fix that?), so use a larger chunk /\x2f size so it at least happens less often. let chunkSizeBackwards = ppixiv.ios? 100:25; let firstNearbyMediaIdIdx = allMediaIds.indexOf(firstNearbyMediaId); if(firstNearbyMediaIdIdx != -1 && firstNearbyMediaIdIdx == firstLoadedMediaIdIdx) startIdx -= chunkSizeBackwards; } /\x2f Clamp the range. startIdx = Math.max(startIdx, 0); endIdx = Math.min(endIdx, allMediaIds.length-1); endIdx = Math.max(startIdx, endIdx); /\x2f make sure startIdx <= endIdx /\x2f Expand the list outwards so we have enough to fill the screen. This is an approximation: /\x2f we don't know how big thumbs will be, but we know they shouldn't be much bigger than /\x2f desiredPixels in area, and we know the area of the screen. If we have thumbs that will /\x2f take more area than the screen, we know we have enough thumbs to fill it. /\x2f /\x2f We'll expand in both directions if possible, so if we have a targetMediaId and it's in /\x2f the middle, it'll stay in the middle if possible. Expand to twice the screen area, since /\x2f some of the thumbs we'll create will only be partially onscreen. let { desiredPixels, containerWidth } = this.sizingStyle; let viewPixels = containerWidth * this.scrollContainer.offsetHeight; viewPixels *= 2; while(1) { let totalThumbs = (endIdx - startIdx) + 1; if(totalThumbs >= allMediaIds.length) break; let totalPixels = totalThumbs * desiredPixels; if(totalPixels >= viewPixels) break; if(startIdx > 0) startIdx--; if(endIdx + 1 < allMediaIds.length) endIdx++; } return { startIdx, endIdx }; } /\x2f Return the first and last media IDs that are nearby (or all of them if all is true). getNearbyMediaIds({all=false}={}) { let mediaIds = []; for(let [mediaId, element] of Object.entries(this.thumbs)) { if(element.dataset.nearby) mediaIds.push(mediaId); } if(all) return mediaIds; else return [mediaIds[0], mediaIds[mediaIds.length-1]]; } /\x2f Return the first and last media IDs that's currently loaded into thumbs. getLoadedMediaIds() { let mediaIds = Object.keys(this.thumbs); let firstLoadedMediaId = mediaIds[0]; let lastLoadedMediaId = mediaIds[mediaIds.length-1]; return [firstLoadedMediaId, lastLoadedMediaId]; } refreshImages({ targetMediaId=null, /\x2f If true, clear thumbs before refreshing, clearing out any accumulated offscreen thumbs. purge=false, /\x2f For diagnostics, this tells us what triggered this refresh. cause }={}) { if(this.dataSource == null) return; /\x2f Update the thumbnail size style. let oldSizingStyle = this.sizingStyle; this.sizingStyle = this.makeThumbnailSizingStyle(); this.grid.sizingStyle = this.sizingStyle; /\x2f Save the scroll position relative to the first thumbnail. Do this before making /\x2f any changes. let savedScroll = this.saveScrollPosition(); let {padding, containerWidth} = this.sizingStyle; this.root.style.setProperty('--thumb-padding', \`\${padding}px\`); this.root.style.setProperty('--container-width', \`\${containerWidth}px\`); /\x2f These are overridden for each thumb, but the base size is used for the header. this.root.style.setProperty("--thumb-width", \`\${this.sizingStyle.thumbWidth}px\`); this.root.style.setProperty("--row-height", \`\${this.sizingStyle.thumbHeight}px\`); /\x2f If purge is true or the sizing style changed, clear thumbs and start over. if(oldSizingStyle && JSON.stringify(oldSizingStyle) != JSON.stringify(this.sizingStyle)) purge = true; if(purge) { /\x2f If we don't have a targetMediaId, set it to the scroll media ID so we'll recreate /\x2f thumbs near where we were. targetMediaId ??= savedScroll?.mediaId; /\x2f console.log(\`Resetting view due to sizing change, target: \${targetMediaId}\`); this._clearThumbs(); } /\x2f Get all media IDs from the data source. let { allMediaIds, mediaIdPages } = this.getDataSourceMediaIds(); /\x2f If targetMediaId isn't in the list, this might be a manga page beyond the first that /\x2f isn't displayed, so try the first page instead. if(targetMediaId != null && allMediaIds.indexOf(targetMediaId) == -1) targetMediaId = helpers.mediaId.getMediaIdFirstPage(targetMediaId); /\x2f Get the range of media IDs to display. let { startIdx, endIdx } = this.getMediaIdsToDisplay({ allMediaIds, targetMediaId, }); let mediaIds = allMediaIds.slice(startIdx, endIdx+1); /\x2f If the new media ID list doesn't overlap the old list, clear out the list and start /\x2f over. let currentMediaIds = Object.keys(this.thumbs); let firstExistingIdx = mediaIds.indexOf(currentMediaIds[0]); let lastExistingIdx = mediaIds.indexOf(currentMediaIds[currentMediaIds.length-1]); let incrementalUpdate = false; if(firstExistingIdx != -1 && lastExistingIdx != -1) { let currentMediaIdsSubset = mediaIds.slice(firstExistingIdx, lastExistingIdx+1); incrementalUpdate = helpers.other.arrayEqual(currentMediaIdsSubset, currentMediaIds); } /\x2f If this isn't an incremental update, clear the list. if(!incrementalUpdate) { /\x2f This isn't an incremental update. It's a new search, or something has happened that /\x2f added or removed thumbs in the middle of the list, like expanding manga pages. this._clearThumbs(); /\x2f If we're targetting an image, set firstExistingIdx and lastExistingIdx so we'll add /\x2f forwards starting at that image, then add the images before it backwards. This way /\x2f that image will always be at the start of a row, which makes restoring the scroll /\x2f position much more consistent. If we're not, just add all images forwards. let restoreIdx = mediaIds.indexOf(targetMediaId); if(restoreIdx != -1) { lastExistingIdx = restoreIdx-1; firstExistingIdx = restoreIdx; } else { lastExistingIdx = -1; firstExistingIdx = 0; } } /\x2f Add thumbs to the end. for(let idx = lastExistingIdx + 1; idx < mediaIds.length; ++idx) { let mediaId = mediaIds[idx]; let searchPage = mediaIdPages[mediaId]; let node = this.createThumb(mediaId, searchPage); helpers.other.addToEnd(this.thumbs, mediaId, node); this.grid.addThumbToRow(node, {atEnd: true}); } /\x2f Add thumbs to the beginning. for(let idx = firstExistingIdx - 1; idx >= 0; --idx) { let mediaId = mediaIds[idx]; let searchPage = mediaIdPages[mediaId]; let node = this.createThumb(mediaId, searchPage); this.thumbs = helpers.other.addToBeginning(this.thumbs, mediaId, node); this.grid.addThumbToRow(node, {atEnd: false}); } this.restoreScrollPosition(savedScroll); /\x2f this.sanityCheckThumbList(); } /\x2f Clear the view. _clearThumbs() { for(let node of Object.values(this.thumbs)) { node.remove(); for(let observer of this.intersectionObservers) observer.unobserve(node); } this.thumbs = {}; this.grid.clear(); } /\x2f Create a thumbnail. createThumb(mediaId, searchPage) { /\x2f makeSVGUnique is disabled here as a small optimization, since these SVGs don't need it. let entry = this.createTemplate({ name: "template-thumbnail", makeSVGUnique: false, html: \`
\${ helpers.createIcon("mat:block", { classes: ["muted-icon"] }) }
\`}); entry.dataset.id = mediaId; if(searchPage != null) entry.dataset.searchPage = searchPage; for(let observer of this.intersectionObservers) observer.observe(entry); this.setupThumb(entry); return entry; } /\x2f Return { thumbWidth, thumbHeight} for mediaId. _thumbnailSize(mediaId) { /\x2f The sizing style gives us the base thumbnail size. let { thumbWidth, thumbHeight, desiredPixels } = this.sizingStyle; /\x2f Anything but illusts use the default width. let { type } = helpers.mediaId.parse(mediaId); if(type != "illust" && type != "file" && type != "folder") return { thumbWidth, thumbHeight }; if(this.sizingStyle.thumbnailStyle == "square") return { thumbWidth, thumbHeight }; /\x2f The manga view preloads thumbs so we can always get the aspect ratio from extraCache. let aspectRatio = null; if(this.dataSource?.name == "manga") { aspectRatio = ppixiv.extraCache.getMediaAspectRatioSync(mediaId); if(aspectRatio == null) { console.warn(\`Manga view didn't cache the aspect ratio for \${mediaId}\`); aspectRatio = 1; } } else { /\x2f Get the aspect ratio from media info. If this is a manga page this won't be known, /\x2f and getImageDimensions will use the first page's dimensions. let mediaInfo = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }); if(mediaInfo == null) throw new Error(\`Missing media info data for \${mediaId}\`); let { width, height } = ppixiv.mediaCache.getImageDimensions(mediaInfo, mediaId); if(width == null) return { thumbWidth, thumbHeight }; aspectRatio = width / height; } /\x2f Set the thumbnail size to have an area of desiredPixels with the aspect ratio we've chosen. /\x2f This gives thumbnails a similar amount of screen space whether they're portrait or landscape, /\x2f and keeps the overall number of thumbs on screen at once mostly predictable. Put a limit on /\x2f how narrow are, so extremely wide strip images don't take over the row. aspectRatio = helpers.math.clamp(aspectRatio, 1/3, 3); thumbWidth = Math.sqrt(desiredPixels * aspectRatio); thumbHeight = thumbWidth / aspectRatio; thumbWidth = Math.round(thumbWidth); thumbHeight = Math.round(thumbHeight); return { thumbWidth, thumbHeight }; } _setThumbnailSize(mediaId, element) { let { thumbWidth, thumbHeight } = this._thumbnailSize(mediaId); /\x2f Store the preferred thumbnail size. element.origWidth = thumbWidth; element.origHeight = thumbHeight; } /\x2f If element isn't loaded and we have media info for it, set it up. setupThumb(element) { let mediaId = element.dataset.id; if(mediaId == null) return; let { id: thumbId, type: thumbType } = helpers.mediaId.parse(mediaId); /\x2f On hover, use StopAnimationAfter to stop the animation after a while. this.addAnimationListener(element); this._setThumbnailSize(mediaId, element); if(thumbType == "user" || thumbType == "bookmarks") { /\x2f This is a user thumbnail rather than an illustration thumbnail. It just shows a small subset /\x2f of info. let userId = thumbId; let link = element.querySelector("a.thumbnail-link"); if(thumbType == "user") link.href = \`/users/\${userId}/artworks#ppixiv\`; else link.href = \`/users/\${userId}/bookmarks/artworks#ppixiv\`; link.dataset.userId = userId; let quickUserData = ppixiv.extraCache.getQuickUserData(userId); if(quickUserData == null) { /\x2f We should always have this data for users if the data source asked us to display this user. throw new Error(\`Missing quick user data for user ID \${userId}\`); } let thumb = element.querySelector(".thumb"); thumb.src = quickUserData.profileImageUrl; let label = element.querySelector(".thumbnail-label"); label.hidden = false; label.querySelector(".label").innerText = quickUserData.userName; return; } if(thumbType != "illust" && thumbType != "file" && thumbType != "folder") throw "Unexpected thumb type: " + thumbType; /\x2f Get media info. This should always be registered by the data source. let info = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }); if(info == null) throw new Error(\`Missing media info data for \${mediaId}\`); /\x2f Set this thumb. let { page } = helpers.mediaId.parse(mediaId); let url = info.previewUrls[page]; let thumb = element.querySelector(".thumb"); let [illustId, illustPage] = helpers.mediaId.toIllustIdAndPage(mediaId); /\x2f Check if this illustration is muted (blocked). let mutedTag = ppixiv.muting.anyTagMuted(info.tagList); let mutedUser = ppixiv.muting.isUserIdMuted(info.userId); if(mutedTag || mutedUser) { /\x2f The image will be obscured, but we still shouldn't load the image the user blocked (which /\x2f is something Pixiv does wrong). Load the user profile image instead. thumb.src = ppixiv.mediaCache.getProfilePictureUrl(info.userId); element.classList.add("muted"); let mutedLabel = element.querySelector(".muted-label"); /\x2f Quick hack to look up translations, since we're not async: (async() => { if(mutedTag) mutedTag = await ppixiv.tagTranslations.getTranslation(mutedTag); mutedLabel.textContent = mutedTag? mutedTag:info.userName; })(); /\x2f We can use this if we want a "show anyway' UI. thumb.dataset.mutedUrl = url; } else { thumb.src = url; element.classList.remove("muted"); LocalAPI.thumbnailWasLoaded(url); /\x2f Let ExtraCache know about this image, so we'll learn the image's aspect ratio. ppixiv.extraCache.registerLoadingThumbnail(mediaId, thumb); /\x2f Try to set up the aspect ratio. this.thumbImageLoadFinished(element, { cause: "setup" }); } /\x2f Set the link. Setting dataset.mediaId will allow this to be handled with in-page /\x2f navigation, and the href will allow middle click, etc. to work normally. let link = element.querySelector("a.thumbnail-link"); if(thumbType == "folder") { /\x2f This is a local directory. We only expect to see this while on the local /\x2f data source. Clear any search when navigating to a subdirectory. let args = new helpers.args("/"); LocalAPI.getArgsForId(mediaId, args); link.href = args.url; } else { link.href = getUrlForMediaId(mediaId).url; } link.dataset.mediaId = mediaId; link.dataset.userId = info.userId; element.querySelector(".ugoira-icon").hidden = info.illustType != 2 && info.illustType != "video"; helpers.html.setClass(element, "dot", helpers.pixiv.tagsContainDot(info.tagList)); /\x2f Set expanded-thumb if this is an expanded manga post. This is also updated in /\x2f setMediaIdExpanded. Set the border to a random-ish value to try to make it /\x2f easier to see the boundaries between manga posts. It's hard to guarantee that it /\x2f won't be the same color as a neighboring post, but that's rare. Using the illust /\x2f ID means the color will always be the same. The saturation is a bit low so these /\x2f colors aren't blinding. this.refreshExpandedThumb(element); helpers.html.setClass(link, "first-page", illustPage == 0); helpers.html.setClass(link, "last-page", illustPage == info.pageCount-1); link.style.borderBottomColor = \`hsl(\${illustId}deg 50% 50%)\`; this.refreshBookmarkIcon(element); /\x2f Set the label. This is only actually shown in following views. let label = element.querySelector(".thumbnail-label"); if(thumbType == "folder") { /\x2f The ID is based on the filename. Use it to show the directory name in the thumbnail. let parts = mediaId.split("/"); let basename = parts[parts.length-1]; let label = element.querySelector(".thumbnail-label"); label.hidden = false; label.querySelector(".label").innerText = basename; } else { label.hidden = true; } } /\x2f Based on the dimensions of the container and a desired pixel size of thumbnails, /\x2f figure out how many columns to display to bring us as close as possible to the /\x2f desired size. Return the corresponding CSS style attributes. /\x2f /\x2f container is the containing block (eg. ul.thumbnails). makeThumbnailSizingStyle() { /\x2f The thumbnail mode is included here so changes to it trigger a refresh. let thumbnailStyle = ppixiv.settings.get("thumbnail_style"); let desiredSize = ppixiv.settings.get("thumbnail-size", 4); desiredSize = MenuOptionsThumbnailSizeSlider.thumbnailSizeForValue(desiredSize); /\x2f Pack images more tightly on mobile. let padding = ppixiv.mobile? 3:15; /\x2f The container might have a fractional size, and clientWidth will round it, which is /\x2f wrong for us: if the container is 500.75 wide and we calculate a fit for 501, the result /\x2f won't actually fit. Get the bounding box instead, which isn't rounded. /\x2f let containerWidth = container.parentNode.clientWidth; let containerWidth = Math.floor(this.root.getBoundingClientRect().width); let containerHeight = Math.floor(this.scrollContainer.getBoundingClientRect().height); let columns = containerWidth / desiredSize; columns = Math.floor(columns); columns = Math.max(columns, 1); let remainingWidth = containerWidth - padding*(columns-1); let thumbWidth = Math.floor(remainingWidth / columns); let thumbHeight = Math.floor(thumbWidth); containerWidth = Math.floor(thumbWidth * columns + padding*(columns-1)); /\x2f Limit the number of visible thumbs, so we don't load too much data at once. Allow /\x2f unlimited columns for local images. let maxThumbs = this.dataSource?.isVView? 500:40; let rows = window.innerHeight / thumbWidth; if(columns * rows > maxThumbs) { columns = maxThumbs / rows; containerWidth = Math.floor(thumbWidth*columns + padding*(columns-1)); } let desiredPixels = thumbWidth * thumbHeight; return { thumbnailStyle, padding, thumbWidth, thumbHeight, containerWidth, containerHeight, desiredPixels, /\x2f This list just forces a refresh if any values inside it change. deps: [ ppixiv.settings.get("pixiv_cdn"), ], }; } /\x2f Verify that thumbs we've created are in sync with this.thumbs. sanityCheckThumbList() { let actual = []; for(let thumb of this.thumbnailBox.children) actual.push(thumb.dataset.id); let expected = Object.keys(this.thumbs); if(JSON.stringify(actual) != JSON.stringify(expected)) { console.log("actual ", actual); console.log("expected", expected); } } thumbnailClick(e) { /\x2f See if this is a click on the manga page toggle. let pageCountBox = e.target.closest(".manga-info-box"); if(pageCountBox) { e.preventDefault(); e.stopPropagation(); let idNode = pageCountBox.closest("[data-id]"); let mediaId = idNode.dataset.id; this.setMediaIdExpanded(mediaId, !this.isMediaIdExpanded(mediaId)); } } /\x2f Save the current scroll position relative to the first visible thumbnail. /\x2f The result can be used with restoreScrollPosition. saveScrollPosition() { let firstVisibleThumbNode = this.getFirstFullyOnscreenThumb(); if(firstVisibleThumbNode == null) return null; /\x2f Save relative to the row instead of the thumb, since the thumb's offsetParent is the /\x2f row and its offsetTop is 0. let row = firstVisibleThumbNode.parentNode; return { savedScroll: helpers.html.saveScrollPosition(this.scrollContainer, row), mediaId: firstVisibleThumbNode.dataset.id, } } /\x2f Restore the scroll position from a position saved by saveScrollPosition. restoreScrollPosition(scroll) { if(scroll == null) return false; /\x2f Find the thumbnail for the mediaId the scroll position was saved at. let restoreScrollPositionNode = this.getThumbnailForMediaId(scroll.mediaId); if(restoreScrollPositionNode == null) return false; let row = restoreScrollPositionNode.parentNode; helpers.html.restoreScrollPosition(this.scrollContainer, row, scroll.savedScroll); return true; } /\x2f Set whether the given thumb is expanded. /\x2f /\x2f We can store a thumb being explicitly expanded or explicitly collapsed, overriding the /\x2f current default. setMediaIdExpanded(mediaId, newValue) { mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); this.expandedMediaIds.set(mediaId, newValue); /\x2f Clear this ID's isMediaIdExpanded cache, if any. if(this._mediaIdExpandedCache) this._mediaIdExpandedCache.delete(mediaId); this.saveExpandedMediaIds(); /\x2f This will cause thumbnails to be added or removed, so refresh. Allow this to purge the /\x2f thumbnail list. This will trigger a full refresh since we're changing thumbs in the /\x2f middle, which can be slow if it recreates a huge accumulated thumbnail list. this.refreshImages({cause: "manga-expansion-change", purge: true}); if(!newValue) { /\x2f After collapsing a manga post, scroll the first page onscreen. this.scrollToMediaId(helpers.mediaId.getMediaIdFirstPage(mediaId)); } } /\x2f Set whether thumbs are expanded or collapsed by default. toggleExpandingMediaIdsByDefault() { /\x2f If the new setting is the same as the expand_manga_thumbnails setting, just /\x2f remove expand-thumbs. Otherwise, set it to the overridden setting. let args = helpers.args.location; let newValue = !this.mediaIdsExpandedByDefault; if(newValue == ppixiv.settings.get("expand_manga_thumbnails")) args.hash.delete("expand-thumbs"); else args.hash.set("expand-thumbs", newValue? "1":"0"); /\x2f Clear manually expanded/unexpanded thumbs, and navigate to the new setting. delete args.state.expandedMediaIds; helpers.navigate(args); } loadExpandedMediaIds() { /\x2f Load expandedMediaIds. let args = helpers.args.location; let mediaIds = args.state.expandedMediaIds ?? {}; this.expandedMediaIds = new Map(Object.entries(mediaIds)); /\x2f Load mediaIdsExpandedByDefault. let expandThumbs = args.hash.get("expand-thumbs"); if(expandThumbs == null) this.mediaIdsExpandedByDefault = ppixiv.settings.get("expand_manga_thumbnails"); else this.mediaIdsExpandedByDefault = expandThumbs == "1"; } /\x2f Store this.expandedMediaIds to history. saveExpandedMediaIds() { let args = helpers.args.location; args.state.expandedMediaIds = Object.fromEntries(this.expandedMediaIds); helpers.navigate(args, { addToHistory: false, cause: "viewing-page", sendPopstate: false }); } /\x2f If mediaId is a manga post, return true if it should be expanded to show its pages. isMediaIdExpanded(mediaId) { /\x2f This is called a lot and becomes a bottleneck on large searches, so cache results. this._mediaIdExpandedCache ??= new Map(); if(!this._mediaIdExpandedCache.has(mediaId)) this._mediaIdExpandedCache.set(mediaId, this._isMediaIdExpanded(mediaId)); return this._mediaIdExpandedCache.get(mediaId); } _isMediaIdExpanded(mediaId) { /\x2f Never expand manga posts on data sources that include manga pages themselves. /\x2f This can result in duplicate media IDs. if(!this.dataSource?.allowExpandingMangaPages) return false; mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); /\x2f Only illust IDs can be expanded. let { type } = helpers.mediaId.parse(mediaId); if(type != "illust") return false; /\x2f Check if the user has manually expanded or collapsed the image. if(this.expandedMediaIds.has(mediaId)) return this.expandedMediaIds.get(mediaId); /\x2f The media ID hasn't been manually expanded or unexpanded. If we're not expanding /\x2f by default, it's unexpanded. if(!this.mediaIdsExpandedByDefault) return false; /\x2f If the image is muted, never expand it by default, even if we're set to expand by default. /\x2f We'll just show a wall of muted thumbs. let info = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }); if(info != null) { let mutedTag = ppixiv.muting.anyTagMuted(info.tagList); let mutedUser = ppixiv.muting.isUserIdMuted(info.userId); if(mutedTag || mutedUser) return false; } /\x2f Otherwise, it's expanded by default if it has more than one page. Note that if we don't /\x2f have media info yet, mediaInfoLoaded will refresh again once it becomes available. if(info == null || info.pageCount == 1) return false; return true; } /\x2f Refresh the expanded-thumb class on thumbnails after expanding or unexpanding a manga post. refreshExpandedThumb(thumb) { if(thumb == null) return; /\x2f Don't set expanded-thumb on the manga view, since it's always expanded. let mediaId = thumb.dataset.id; let showExpanded = this.dataSource?.allowExpandingMangaPages && this.isMediaIdExpanded(mediaId); helpers.html.setClass(thumb, "expanded-thumb", showExpanded); let info = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }); let [illustId, illustPage] = helpers.mediaId.toIllustIdAndPage(mediaId); helpers.html.setClass(thumb, "expanded-manga-post", showExpanded); helpers.html.setClass(thumb, "first-manga-page", info && info.pageCount > 1 && illustPage == 0); /\x2f Show the page count if this is a multi-page post (unless we're on the /\x2f manga view itself). let showMangaPage = info && info.pageCount > 1 && this.dataSource?.name != "manga"; let pageCountBox = thumb.querySelector(".manga-info-box"); pageCountBox.hidden = !showMangaPage; if(showMangaPage) { let text = showExpanded? \`\${illustPage+1}/\${info.pageCount}\`:info.pageCount; pageCountBox.querySelector(".page-count").textContent = text; pageCountBox.querySelector(".page-count").hidden = false; helpers.html.setClass(pageCountBox, "show-expanded", showExpanded); } } /\x2f Refresh all expanded thumbs. This is only needed if the default changes. refreshExpandedThumbAll() { for(let thumb of this.getLoadedThumbs()) this.refreshExpandedThumb(thumb); } /\x2f Set things up based on the image dimensions. We can do this immediately if we know the /\x2f thumbnail dimensions already, otherwise we'll do it based on the thumbnail once it loads. thumbImageLoadFinished(element, { cause }) { if(element.dataset.thumbLoaded) return; let mediaId = element.dataset.id; let [illustId, illustPage] = helpers.mediaId.toIllustIdAndPage(mediaId); let thumb = element.querySelector(".thumb"); /\x2f Try to use thumbnail info first. Preferring this makes things more consistent, /\x2f since naturalWidth may or may not be loaded depending on browser cache. let width, height; if(illustPage == 0) { let info = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }); if(info != null) { width = info.width; height = info.height; } } /\x2f If that wasn't available, try to use the dimensions from the image. This is the size /\x2f of the thumb rather than the image, but all we care about is the aspect ratio. if(width == null && thumb.naturalWidth != 0) { width = thumb.naturalWidth; height = thumb.naturalHeight; } if(width == null) return; /\x2f We can't do this until the node is added to the document and it has a size. if(element.offsetWidth == 0) return; element.dataset.thumbLoaded = "1"; /\x2f Set up the thumbnail panning direction, which is based on the image aspect ratio and the /\x2f displayed thumbnail aspect ratio. let aspectRatio = element.offsetWidth / element.offsetHeight; SearchView.createThumbnailAnimation(thumb, width, height, aspectRatio); } /\x2f If the aspect ratio is very narrow, don't use any panning, since it becomes too spastic. /\x2f If the aspect ratio is portrait, use vertical panning. /\x2f If the aspect ratio is landscape, use horizontal panning. /\x2f /\x2f If it's in between, don't pan at all, since we don't have anywhere to move and it can just /\x2f make the thumbnail jitter in place. /\x2f /\x2f Don't pan muted images. /\x2f /\x2f containerAspectRatio is the aspect ratio of the box the thumbnail is in. If the /\x2f thumb is in a 2:1 landscape box, we'll adjust the min and max aspect ratio accordingly. static getThumbnailPanningDirection(thumb, width, height, containerAspectRatio) { /\x2f Disable panning if we don't have the image size. Local directory thumbnails /\x2f don't tell us the dimensions in advance. if(width == null || height == null) { helpers.html.setClass(thumb, "vertical-panning", false); helpers.html.setClass(thumb, "horizontal-panning", false); return null; } let aspectRatio = width / height; aspectRatio /= containerAspectRatio; let minAspectForPan = 1.1; let maxAspectForPan = 4; if(aspectRatio > (1/maxAspectForPan) && aspectRatio < 1/minAspectForPan) return "vertical"; else if(aspectRatio > minAspectForPan && aspectRatio < maxAspectForPan) return "horizontal"; else return null; } static createThumbnailAnimation(thumb, width, height, containerAspectRatio) { if(ppixiv.mobile) return null; /\x2f Create the animation, or update it in-place if it already exists, probably due to the /\x2f window being resized. total_time won't be updated when we do this. let direction = this.getThumbnailPanningDirection(thumb, width, height, containerAspectRatio); if(thumb.panAnimation != null || direction == null) return null; let keyframes = direction == "horizontal"? [ /\x2f This starts in the middle, pans left, pauses, pans right, pauses, returns to the /\x2f middle, then pauses again. { offset: 0.0, easing: "ease-in-out", objectPosition: "left top" }, /\x2f left { offset: 0.4, easing: "ease-in-out", objectPosition: "right top" }, /\x2f pan right { offset: 0.5, easing: "ease-in-out", objectPosition: "right top" }, /\x2f pause { offset: 0.9, easing: "ease-in-out", objectPosition: "left top" }, /\x2f pan left { offset: 1.0, easing: "ease-in-out", objectPosition: "left top" }, /\x2f pause ]: [ /\x2f This starts at the top, pans down, pauses, pans back up, then pauses again. { offset: 0.0, easing: "ease-in-out", objectPosition: "center top" }, { offset: 0.4, easing: "ease-in-out", objectPosition: "center bottom" }, { offset: 0.5, easing: "ease-in-out", objectPosition: "center bottom" }, { offset: 0.9, easing: "ease-in-out", objectPosition: "center top" }, { offset: 1.0, easing: "ease-in-out", objectPosition: "center top" }, ]; let animation = new Animation(new KeyframeEffect(thumb, keyframes, { duration: 4000, iterations: Infinity, /\x2f The full animation is 4 seconds, and we want to start 20% in, at the halfway /\x2f point of the first left-right pan, where the pan is exactly in the center where /\x2f we are before any animation. This is different from vertical panning, since it /\x2f pans from the top, which is already where we start (top center). delay: direction == "horizontal"? -800:0, })); animation.id = direction == "horizontal"? "horizontal-pan":"vertical-pan"; thumb.panAnimation = animation; return animation; } /\x2f element is a thumbnail element. On mouseover, start the pan animation, and create /\x2f a StopAnimationAfter to prevent the animation from running forever. /\x2f /\x2f We create the pan animations programmatically instead of with CSS, since for some /\x2f reason element.getAnimations is extremely slow and often takes 10ms or more. CSS /\x2f can't be used to pause programmatic animations, so we have to play/pause it manually /\x2f too. addAnimationListener(element) { if(ppixiv.mobile) return; if(element.addedAnimationListener) return; element.addedAnimationListener = true; element.addEventListener("mouseover", (e) => { if(ppixiv.settings.get("disable_thumbnail_panning") || ppixiv.settings.get("thumbnail_style") == "aspect" || ppixiv.mobile) return; let thumb = element.querySelector(".thumb"); let anim = thumb.panAnimation; if(anim == null) return; /\x2f Start playing the animation. anim.play(); /\x2f Stop if StopAnimationAfter is already running for this thumb. if(this.stopAnimation?.animation == anim) return; /\x2f If we were running it on another thumb and we missed the mouseout for /\x2f some reason, remove it. This only needs to run on the current hover. if(this.stopAnimation) { this.stopAnimation.shutdown(); this.stopAnimation = null; } this.stopAnimation = new StopAnimationAfter(anim, 6, 1, anim.id == "vertical-pan"); /\x2f Remove it when the mouse leaves the thumb. We'll actually respond to mouseover/mouseout /\x2f for elements inside the thumb too, but it doesn't cause problems here. element.addEventListener("mouseout", (e) => { this.stopAnimation.shutdown(); this.stopAnimation = null; anim.pause(); }, { once: true, signal: this.stopAnimation.abort.signal }); }); } /\x2f Refresh the thumbnail for mediaId. /\x2f /\x2f This is used to refresh the bookmark icon when changing a bookmark. refreshThumbnail(mediaId) { /\x2f If this is a manga post, refresh all thumbs for this media ID, since bookmarking /\x2f a manga post is shown on all pages if it's expanded. let mediaInfo = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }); if(mediaInfo == null) return; let thumbnailElement = this.getThumbnailForMediaId(mediaId); if(thumbnailElement != null) this.refreshBookmarkIcon(thumbnailElement); /\x2f If we're displaying individual pages for this media ID, check them too. for(let page = 0; page < mediaInfo.pageCount; ++page) { let pageMediaId = helpers.mediaId.getMediaIdForPage(mediaId, page); thumbnailElement = this.getThumbnailForMediaId(pageMediaId); if(thumbnailElement != null) this.refreshBookmarkIcon(thumbnailElement); } } /\x2f If the data source gives us a URL to use as a header image, update it. refreshHeader() { let img = this.artistHeader.querySelector("img"); let headerStripURL = this.dataSource?.uiInfo?.headerStripURL; if(headerStripURL == null) { this.artistHeader.hidden = true; img.src = helpers.other.blankImage; return; } if(img.src == headerStripURL) return; /\x2f Save the scroll position in case we're turning the header on. let savedScroll = this.saveScrollPosition(); /\x2f If thumbnail panning is turned off, disable this animation too. helpers.html.setClass(img, "animated", ppixiv.mobile || !ppixiv.settings.get("disable_thumbnail_panning")); /\x2f Start the animation. img.classList.remove("loaded"); img.onload = () => img.classList.add("loaded"); /\x2f Set the URL. img.src = headerStripURL ?? helpers.other.blankImage; this.artistHeader.hidden = false; this.restoreScrollPosition(savedScroll); } /\x2f Set the bookmarked heart for thumbnailElement. This can change if the user bookmarks /\x2f or un-bookmarks an image. refreshBookmarkIcon(thumbnailElement) { if(this.dataSource && this.dataSource.name == "manga") return; let mediaId = thumbnailElement.dataset.id; if(mediaId == null) return; /\x2f Get thumbnail info. let mediaInfo = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }); if(mediaInfo == null) return; /\x2f aiType is 0 or 1 for false and 2 for true. let showAI = mediaInfo.aiType == 2; let showBookmarkHeart = mediaInfo.bookmarkData != null; if(this.dataSource != null && !this.dataSource.showBookmarkIcons) showBookmarkHeart = false; /\x2f On mobile, don't show ai-image if we're showing a bookmark to reduce clutter. if(ppixiv.mobile && showAI && showBookmarkHeart) showAI = false; thumbnailElement.querySelector(".ai-image").hidden = !showAI; thumbnailElement.querySelector(".heart.public").hidden = !showBookmarkHeart || mediaInfo.bookmarkData.private; thumbnailElement.querySelector(".heart.private").hidden = !showBookmarkHeart || !mediaInfo.bookmarkData.private; } /\x2f Refresh all thumbs after the mute list changes. refreshAfterMuteChange() { this._mediaIdExpandedCache = null; this.refreshImages({cause: "mutes-changed", purge: true}); } getLoadedThumbs() { return Object.values(this.thumbs); } /\x2f Scroll to mediaId if it's available. This is called when we display the thumbnail view /\x2f after coming from an illustration. scrollToMediaId(mediaId) { if(mediaId == null) return false; /\x2f Make sure this image has a thumbnail created if possible. this.refreshImages({ targetMediaId: mediaId, cause: "scroll-to-id" }); let thumb = this.getThumbnailForMediaId(mediaId, { fallbackOnPage1: true }); if(thumb == null) return false; /\x2f If we were displaying an image, pulse it to make it easier to find your place. this.pulseThumbnail(mediaId); /\x2f Get the vertical position and height of the thumb. Use the containing row instead /\x2f of the thumb itself for this, since the thumb's offsetParent is the row and its /\x2f offsetTop is 0. let { offsetTop, offsetHeight } = thumb.parentNode; /\x2f Stop if the thumb is already fully visible. if(offsetTop >= this.scrollContainer.scrollTop && offsetTop + offsetHeight < this.scrollContainer.scrollTop + this.scrollContainer.offsetHeight) return true; let y = offsetTop + offsetHeight/2 - this.scrollContainer.offsetHeight/2; /\x2f If we set y outside of the scroll range, iOS will incorrectly report scrollTop briefly. /\x2f Clamp the position to avoid this. y = helpers.math.clamp(y, 0, this.scrollContainer.scrollHeight - this.scrollContainer.offsetHeight); this.scrollContainer.scrollTop = y; return true; }; /\x2f Return the bounding rectangle for the given mediaId. getRectForMediaId(mediaId) { let thumb = this.getThumbnailForMediaId(mediaId, { fallbackOnPage1: true }); if(thumb == null) return null; return thumb.getBoundingClientRect(); } pulseThumbnail(mediaId) { /\x2f If animations are enabled, they indicate the last viewed image, so we don't need this. if(ppixiv.settings.get("animations_enabled")) return; let thumb = this.getThumbnailForMediaId(mediaId); if(thumb == null) return; this.stopPulsingThumbnail(); this.flashingImage = thumb; thumb.classList.add("flash"); }; /\x2f Work around a bug in CSS animations: even if animation-iteration-count is 1, /\x2f the animation will play again if the element is hidden and displayed again, which /\x2f causes previously-flashed thumbnails to flash every time we exit and reenter /\x2f thumbnails. stopPulsingThumbnail() { if(this.flashingImage == null) return; this.flashingImage.classList.remove("flash"); this.flashingImage = null; }; }; /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/screen-search/search-view.js `), "/vview/sites/data-source.js": loadBlob("application/javascript", ` import Widget from '/vview/widgets/widget.js'; import { DropdownMenuOpener } from '/vview/widgets/dropdown.js'; import IllustIdList from '/vview/sites/illust-id-list.js'; import LocalAPI from '/vview/misc/local-api.js'; import { helpers, SafetyBackoffTimer } from '/vview/misc/helpers.js'; export default class DataSource extends EventTarget { constructor({url}) { super(); this.url = new URL(url); this. _resetLoadedPages(); }; _resetLoadedPages() { this.idList = new IllustIdList(); this.loadingPages = {}; this.loadedPages = {}; this.firstEmptyPage = -1; } async init() { /\x2f If this data source supports a start page, store the page we started on. let args = new helpers.args(this.url); this.initialPage = this.getStartPage(args); /\x2f if(this.initialPage > 1) /\x2f console.log("Starting at page", this.initialPage); } /\x2f If a data source returns a name, we'll display any .data-source-specific elements in /\x2f the thumbnail view with that name. get name() { return null; } toString() { return \`\${this.name}\`; } /\x2f If true, allow expanding manga pages in results. If this is false, manga pages are never /\x2f expanded and the button to enable it will be disabled. /\x2f /\x2f This should be false for data sources that don't return images, such as user searches, since /\x2f there will never be images to expand. It must be false for data sources that can return /\x2f manga pages themselves, since expanding manga pages is incompatible with manga pages being /\x2f included in results. get allowExpandingMangaPages() { return true; } /\x2f Return true if all pages have been loaded. get loadedAllPages() { return this.firstEmptyPage != -1; } /\x2f Return this data source's URL as a helpers.args. get args() { return new helpers.args(this.url); } /\x2f startup() is called when the data source becomes active, and shutdown is called when /\x2f it's done. This can be used to add and remove event handlers on the UI. startup() { this.active = true; } shutdown() { this.active = false; } /\x2f Return the URL to use to return to this search. For most data sources, this is the URL /\x2f it was initialized with. get searchUrl() { return this.url; } /\x2f This returns the widget class that can be instantiated for this data source's UI. get ui() { return null; } /\x2f Load the given page. Return true if the page was loaded. loadPage(page, { cause }={}) { /\x2f Note that we don't remove entries from loadingPages when they finish, so /\x2f future calls to loadPage will still return a promise for that page that will /\x2f resolve immediately. let result = this.loadedPages[page] || this.loadingPages[page]; if(result == null) { result = this._loadPageAsync(page, { cause }); this.loadingPages[page] = result; result.finally(() => { /\x2f Move the load from loadingPages to loadedPages. delete this.loadingPages[page]; this.loadedPages[page] = result; }); } return result; } /\x2f Return true if the given page is either loaded, or currently being loaded by a call to loadPage. isPageLoadedOrLoading(page) { if(this.idList.isPageLoaded(page)) return true; if(this.loadedPages[page] || this.loadingPages[page]) return true; return false; } /\x2f Return true if any page is currently loading. get isAnyPageLoading() { for(let page in this.loadingPages) if(this.loadingPages[page]) return true; return false; } /\x2f Return true if the data source can load the given page. canLoadPage(page) { if(page < 1) return false; /\x2f Most data sources can load any page if they haven't loaded a page yet. Once /\x2f a page is loaded, they only load contiguous pages. if(!this.idList.anyPagesLoaded) return true; /\x2f If we know a page is empty, don't try to load pages beyond it. if(this.firstEmptyPage != -1 && page >= this.firstEmptyPage) return false; /\x2f If we've loaded pages 5-6, we can load anything between pages 4 and 7. let lowestPage = this.idList.getLowestLoadedPage(); let highestPage = this.idList.getHighestLoadedPage(); return page >= lowestPage-1 && page <= highestPage+1; } async _loadPageAsync(page, { cause }) { /\x2f Stop if this page is outside the range this data source can load. if(!this.canLoadPage(page)) { /\x2f console.log(\`Data source can't load page \${page}\`); return; } /\x2f If the page is already loaded, stop. if(this.idList.isPageLoaded(page)) return true; console.log(\`Load page \${page} for: \${cause}\`); /\x2f Before starting, await at least once so we get pushed to the event loop. This /\x2f guarantees that loadPage has a chance to store us in this.loadingPages before /\x2f we do anything that might have side-effects of starting another load. await null; /\x2f Run the actual load. let { mediaIds, allowEmpty } = await this.loadPageInternal(page) ?? { }; /\x2f Register the page if media IDs were returned. if(mediaIds) await this.addPage(page, mediaIds, { allowEmpty }); /\x2f Reduce the start page, which will update the "load more results" button if any. if(this.supportsStartPage && page < this.initialPage) this.initialPage = page; /\x2f If there were no results, then we've loaded the last page. Don't try to load /\x2f any pages beyond this. if(!this.idList.mediaIdsByPage.has(page)) { console.log("No data on page", page); if(this.firstEmptyPage == -1 || page < this.firstEmptyPage) this.firstEmptyPage = page; } else if(this.idList.mediaIdsByPage.get(page).length == 0) { /\x2f A page was added, but it was empty. This is rare and can only happen if the /\x2f data source explicitly adds an empty page, and means there was an empty search /\x2f page that wasn't at the end. This breaks the search view's logic (it expects /\x2f to get something back to trigger another load). Work around this by starting /\x2f the next page. /\x2f /\x2f This is very rare. Use a strong backoff, so if this happens repeatedly for some /\x2f reason, we don't hammer the API loading pages infinitely and get users API blocked. this.emptyPageLoadBackoff ??= new SafetyBackoffTimer(); console.log(\`Load was empty, but not at the end. Delaying before loading the next page...\`); await this.emptyPageLoadBackoff.wait(); console.log(\`Continuing load from \${page+1}\`); return await this.loadPage(page+1); } return true; } /\x2f If a URL for this data source contains a media ID to view, return it. Otherwise, return /\x2f null. getUrlMediaId(args) { /\x2f Most data sources for Pixiv store the media ID in the hash, separated into the /\x2f illust ID and page. let illustId = args.hash.get("illust_id"); if(illustId == null) return null; let page = this.getUrlMangaPage(args); return helpers.mediaId.fromIllustId(illustId, page); } /\x2f If the URL specifies a manga page, return it, otherwise return 0. getUrlMangaPage(args) { if(!args.hash.has("page")) return 0; /\x2f Pages are 1-based in URLs, but 0-based internally. return parseInt(args.hash.get("page"))-1; } /\x2f Set args to include the media ID being viewed. This is usually a media ID that the /\x2f data source returned. setUrlMediaId(mediaId, args) { let [illustId, page] = helpers.mediaId.toIllustIdAndPage(mediaId); if(this.supportsStartPage) { /\x2f Store the page the illustration is on in the hash, so if the page is reloaded while /\x2f we're showing an illustration, we'll start on that page. If we don't do this and /\x2f the user clicks something that came from page 6 while the top of the search results /\x2f were on page 5, we'll start the search at page 5 if the page is reloaded and not find /\x2f the image, which is confusing. let { page: originalPage } = this.idList.getPageForMediaId(illustId); if(originalPage != null) this.setStartPage(args, originalPage); } /\x2f By default, put the illust ID and page in the hash. args.hash.set("illust_id", illustId); if(page == null) args.hash.delete("page"); else args.hash.set("page", page + 1); } /\x2f Store the current page in the URL. /\x2f /\x2f This is only used if supportsStartPage is true. setStartPage(args, page) { /\x2f Remove the page for page 1 to keep the initial URL clean. if(page == 1) args.query.delete("p"); else args.query.set("p", page); } getStartPage(args) { /\x2f If the data source doesn't support this, the start page is always 1. if(!this.supportsStartPage) return 1; let page = args.query.get("p") || "1"; return parseInt(page) || 1; } /\x2f Return the page title to use. get pageTitle() { return "Pixiv"; } /\x2f Set the page icon. setPageIcon() { helpers.setIcon(); } /\x2f If true, "No Results" will be displayed. get hasNoResults() { return this.idList.getFirstId() == null && !this.isAnyPageLoading; } /\x2f This is implemented by the subclass. async loadPageInternal(page) { throw "Not implemented"; } /\x2f Return the estimated number of items per page. get estimatedItemsPerPage() { /\x2f Most newer Pixiv pages show a grid of 6x8 images. Try to match it, so page numbers /\x2f line up. return 48; }; /\x2f Return the screen that should be displayed by default, if no "view" field is in the URL. get defaultScreen() { return "search"; } /\x2f If we're viewing a page specific to a user (an illustration or artist page), return /\x2f the user ID we're viewing. This can change when refreshing the UI. get viewingUserId() { return null; }; /\x2f If a data source is transient, it'll be discarded when the user navigates away instead of /\x2f reused. get transient() { return false; } /\x2f Some data sources can restart the search at a page. get supportsStartPage() { return false; } /\x2f Most searches will only auto-load forwards and display "Load Previous Results" at the top. /\x2f If this is true, the search is allowed to automatically load backwards too. get autoLoadPreviousPages() { return false; } /\x2f Return the "15 / 100" page text to use. This is only used by DataSource_VView. getPageTextForMediaId(mediaId) { return null; } /\x2f Register a page of data. async addPage(page, mediaIds, {...options}={}) { /\x2f If an image view is reloaded, it may no longer be on the same page in the underlying /\x2f search. New posts might have pushed it onto another page, or the search might be /\x2f random. This is confusing if you're trying to mousewheel navigate to other images. /\x2f /\x2f Work around this by making sure the initial image is on the initial page. If we load /\x2f the first page and the image we were on isn't there anymore, insert it into the results. /\x2f It's probably still in the results somewhere, but we can't tell where. /\x2f /\x2f This allows the user to navigate to neighboring images normally. We'll go to different /\x2f images, but at least we can still navigate, and we can get back to where we started /\x2f if the user navigates down and then back up. If the image shows up in real results later, /\x2f it'll be filtered out. let initialMediaId = this.getUrlMediaId(this.args); /\x2f If this data source doesn't return manga pages, always use the first page. if(this.allowExpandingMangaPages) initialMediaId = helpers.mediaId.getMediaIdForPage(initialMediaId, 0); if(page == this.initialPage && initialMediaId != null && this.idList.getPageForMediaId(initialMediaId).page == null && mediaIds.indexOf(initialMediaId) == -1) { /\x2f Make sure the media ID has info before adding it to the list. if(await ppixiv.mediaCache.getMediaInfo(initialMediaId, { full: false })) { console.log(\`Adding initial media ID \${initialMediaId} to initial page \${this.initialPage}\`); mediaIds = [initialMediaId, ...mediaIds]; } } /\x2f Verify that all results have media info registered. for(let mediaId of mediaIds) { let { type, id } = helpers.mediaId.parse(mediaId); if(type == "user" || type == "bookmarks") { if(ppixiv.extraCache.getQuickUserData(id) == null) { console.error(\`Data source returned \${mediaId} without registering user info\`, this); throw new Error(\`Data source returned didn't register user info\`); } } else { if(ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }) == null) { console.error(\`Data source returned \${mediaId} without registering media info\`, this); throw new Error(\`Data source returned didn't register media info\`); } } } this.idList.addPage(page, mediaIds, {...options}); /\x2f Send pageadded asynchronously to let listeners know we added the page. let e = new Event("pageadded"); e.dataSource = this; helpers.other.defer(() => this.dispatchEvent(e)); } /\x2f Send the "updated" event when we want to tell our parent that something has changed. /\x2f This is used when we've added a new page and the search view might want to refresh, /\x2f if the page title should be refreshed, etc. Internal updates don't need to call this. callUpdateListeners() { this.dispatchEvent(new Event("updated")); } /\x2f Return info useful for the container's UI elements: /\x2f /\x2f { /\x2f mediaId, /\x2f media ID associated with the search, for restoring search scroll position /\x2f imageUrl, /\x2f URL for an image related to this search /\x2f imageLinkUrl, /\x2f a URL where imageUrl should link to /\x2f userId, /\x2f a user ID whose avatar should be displayed /\x2f mobileTitle, /\x2f an alternate title for the mobile search menu /\x2f headerStripURL, /\x2f a URL to an image to show at the top of the search /\x2f } /\x2f /\x2f If this changes, the "updated" event will be sent to the data source. get uiInfo() { return { }; } createAndSetButton(parent, createOptions, setupOptions) { let button = helpers.createBoxLink({ asElement: true, ...createOptions }); parent.appendChild(button); this.setItem(button, setupOptions); return button; } /\x2f Create a common search dropdown. button is options to createBoxLink, and items /\x2f is options to setItem. setupDropdown(button, items) { return new DropdownMenuOpener({ button, createDropdown: ({...options}) => { let dropdown = new Widget({ ...options, template: \`
\`, }); for(let {createOptions, setupOptions} of items) this.createAndSetButton(dropdown.root, createOptions, setupOptions); return dropdown; }, }); } /\x2f A helper for setting up UI links. Find the link with the given type, /\x2f set all {key: value} entries as query parameters, and remove any query parameters /\x2f where value is null. Set .selected if the resulting URL matches the current one. /\x2f /\x2f If defaults is present, it tells us the default key that will be used if /\x2f a key isn't present. For example, search.php?s_mode=s_tag is the same as omitting /\x2f s_mode. We prefer to omit it rather than clutter the URL with defaults, but we /\x2f need to know this to figure out whether an item is selected or not. /\x2f /\x2f If a key begins with #, it's placed in the hash rather than the query. setItem(link, {type=null, ...options}={}) { /\x2f If no type is specified, link itself is the link. if(type != null) { link = link.querySelector(\`[data-type='\${type}']\`); if(link == null) { console.warn("Couldn't find button with selector", type); return; } } /\x2f The URL we're adjusting: let args = new helpers.args(this.url); /\x2f Adjust the URL for this button. let { args: newArgs, buttonIsSelected } = this.setItemInUrl(args, options); helpers.html.setClass(link, "selected", buttonIsSelected); link.href = newArgs.url.toString(); }; /\x2f Apply a search filter button to a search URL, activating or deactivating a search /\x2f filter. Return { args, buttonIsSelected }. setItemInUrl(args, { /\x2f The fields selected when this button is activated. For example: { sort: "alpha" } fields=null, /\x2f An optional set of default fields: the values that will be used if the key isn't /\x2f present. defaults=null, /\x2f If true, pressing this button toggles its keys on and off instead of always setting /\x2f them. toggle=false, /\x2f If provided, this allows modifying URLs that put parameters in URL segments instead /\x2f of the query where they belong. If urlFormat is "abc/def/ghi", a key of "/abc" will modify /\x2f the first segment, and so on. urlFormat=null, /\x2f This can be used to adjust the link's URL without affecting anything else. adjustUrl=null }={}) { /\x2f Ignore the language prefix on the URL if any, so it doesn't affect urlFormat. args.path = helpers.pixiv.getPathWithoutLanguage(args.path); /\x2f If urlParts is provided, create a map from "/segment" to a segment number like "/1" that /\x2f args.set uses. let urlParts = {}; if(urlFormat != null) { let parts = urlFormat.split("/"); for(let idx = 0; idx < parts.length; ++idx) urlParts["/" + parts[idx]] = "/" + idx; } /\x2f Collect data for each key. let fieldData = {}; for(let [key, value] of Object.entries(fields)) { let originalKey = key; let defaultValue = null; if(defaults && key in defaults) defaultValue = defaults[key]; /\x2f Convert path keys in fields from /path to their path index. if(key.startsWith("/")) { if(urlParts[key] == null) { console.warn(\`URL key \${key} not specified in URL: \${args}\`); continue; } key = urlParts[key]; } fieldData[key] = { value, originalKey, defaultValue, } } /\x2f This button is selected if all of the keys it sets are present in the URL. let buttonIsSelected = true; for(let [key, {value, defaultValue}] of Object.entries(fieldData)) { /\x2f The value we're setting in the URL: let thisValue = value ?? defaultValue; /\x2f The value currently in the URL: let selectedValue = args.get(key) ?? defaultValue; /\x2f If the URL didn't have the key we're setting, then it isn't selected. if(thisValue != selectedValue) buttonIsSelected = false; /\x2f If the value we're setting is the default, delete it instead. if(defaults != null && thisValue == defaultValue) value = null; args.set(key, value); } /\x2f If this is a toggle and the button is selected, set the fields to their default, /\x2f turning this into an "off" button. if(toggle && buttonIsSelected) { for(let [key, { defaultValue }] of Object.entries(fieldData)) args.set(key, defaultValue); } /\x2f Don't include the page number in search buttons, so clicking a filter goes /\x2f back to page 1. args.set("p", null); if(adjustUrl) adjustUrl(args); return { args, buttonIsSelected }; } /\x2f Return true of the thumbnail view should show bookmark icons for this source. get showBookmarkIcons() { return true; } /\x2f Return the next or previous image to navigate to from mediaId. If we're at the end of /\x2f the loaded results, load the next or previous page. If mediaId is null, return the first /\x2f image. This only returns illusts, not users or folders. /\x2f /\x2f This currently won't load more than one page. If we load a page and it only has users, /\x2f we won't try another page. async getOrLoadNeighboringMediaId(mediaId, next, options={}) { /\x2f See if it's already loaded. let newMediaId = this.idList.getNeighboringMediaId(mediaId, next, options); if(newMediaId != null) return newMediaId; /\x2f We didn't have the new illustration, so we may need to load another page of search results. /\x2f See if we know which page mediaId is on. let page = mediaId != null? this.idList.getPageForMediaId(mediaId).page:null; /\x2f Find the page this illustration is on. If we don't know which page to start on, /\x2f use the initial page. if(page != null) { page += next? +1:-1; if(page < 1) return null; } else { /\x2f If we don't know which page mediaId is on, start from initialPage. page = this.initialPage; } /\x2f Short circuit if we already know this is past the end. This just avoids spamming /\x2f logs. if(!this.canLoadPage(page)) return null; console.log("Loading the next page of results:", page); /\x2f The page shouldn't already be loaded. Double-check to help prevent bugs that might /\x2f spam the server requesting the same page over and over. if(this.idList.isPageLoaded(page)) { console.error(\`Page \${page} is already loaded\`); return null; } /\x2f Load a page. let newPageLoaded = await this.loadPage(page, { cause: "illust navigation" }); if(!newPageLoaded) return null; /\x2f Now that we've loaded data, try to find the new image again. console.log("Finishing navigation after data load"); return this.idList.getNeighboringMediaId(mediaId, next, options); } /\x2f Get the next or previous image to fromMediaId. If we're at the end, loop back /\x2f around to the other end. options is the same as getOrLoadNeighboringMediaId. async getOrLoadNeighboringMediaIdWithLoop(fromMediaId, next, options={}) { /\x2f See if we can keep moving in this direction. let mediaId = await this.getOrLoadNeighboringMediaId(fromMediaId, next, options); if(mediaId) return mediaId; /\x2f We're out of results in this direction. If we're moving backwards, only loop /\x2f if we have all results. Otherwise, we'll go to the last loaded image, but if /\x2f the user then navigates forwards, he'll just go to the next image instead of /\x2f where he came from, which is confusing. if(!next && !this.loadedAllPages) { console.log("Not looping backwards since we don't have all pages"); return null; } return next? this.idList.getFirstId():this.idList.getLastId(); } }; /\x2f This is a base class for data sources that work by loading a regular Pixiv page /\x2f and scraping it. /\x2f /\x2f All of these work the same way. We keep the current URL (ignoring the hash) synced up /\x2f as a valid page URL that we can load. If we change pages or other search options, we /\x2f modify the URL appropriately. export class DataSourceFromPage extends DataSource { constructor(url) { super(url); this.itemsPerPage = 1; this.originalUrl = url; } get estimatedItemsPerPage() { return this.itemsPerPage; } async loadPageInternal(page) { /\x2f Our page URL looks like eg. /\x2f /\x2f https:/\x2fwww.pixiv.net/bookmark.php?p=2 /\x2f /\x2f possibly with other search options. Request the current URL page data. let url = new URL(this.url); /\x2f Update the URL with the current page. url.searchParams.set("p", page); console.log("Loading:", url.toString()); let doc = await helpers.pixivRequest.fetchDocument(url); let mediaIds = this.parseDocument(doc); if(mediaIds == null) { /\x2f The most common case of there being no data in the document is loading /\x2f a deleted illustration. See if we can find an error message. console.error("No data on page"); return; } /\x2f Assume that if the first request returns 10 items, all future pages will too. This /\x2f is usually correct unless we happen to load the last page last. Allow this to increase /\x2f in case that happens. (This is only used by the thumbnail view.) if(this.itemsPerPage == 1) this.itemsPerPage = Math.max(mediaIds.length, this.itemsPerPage); return { mediaIds }; } /\x2f Parse the loaded document and return the media IDs. parseDocument(doc) { throw "Not implemented"; } } /\x2f This extends DataSource with local pagination. /\x2f /\x2f A few API calls just return all results as a big list of IDs. We can handle loading /\x2f them all at once, but it results in a very long scroll box, which makes scrolling /\x2f awkward. This artificially paginates the results. export class DataSourceFakePagination extends DataSource { async loadPageInternal(page) { if(this.pages == null) { let mediaIds = await this.loadAllResults(); this.pages = PaginateMediaIds(mediaIds, this.estimatedItemsPerPage); } let mediaIds = this.pages[page-1] || []; return { mediaIds }; } /\x2f Implemented by the subclass. Load all results, and return the resulting IDs. async loadAllResults() { throw "Not implemented"; } } /\x2f Split a list of media IDs into pages. /\x2f /\x2f In general it's safe for a data source to return a lot of data, and the search view /\x2f will handle incremental loading, but this can be used to split large results apart. export function PaginateMediaIds(mediaIds, itemsPerPage) { /\x2f Paginate the big list of results. let pages = []; let page = null; for(let mediaId of mediaIds) { if(page == null) { page = []; pages.push(page); } page.push(mediaId); if(page.length == itemsPerPage) page = null; } return pages; } /\x2f A helper widget for dropdown lists of tags which refreshes when the data source is updated. export class TagDropdownWidget extends Widget { constructor({dataSource, ...options}) { super({ ...options, template: \`
\`, }); this.dataSource = dataSource; this.dataSource.addEventListener("updated", () => this.refreshTags(), this._signal); this.refreshTags(); } refreshTags() { } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/data-source.js `), "/vview/sites/illust-id-list.js": loadBlob("application/javascript", `/\x2f A list of illustration IDs by page. /\x2f /\x2f Store the list of illustration IDs returned from a search, eg. bookmark.php?p=3, /\x2f and allow looking up the next or previous ID for an illustration. If we don't have /\x2f data for the next or previous illustration, return the page that should be loaded /\x2f to make it available. /\x2f /\x2f We can have gaps in the pages we've loaded, due to history navigation. If you load /\x2f page 1, then jump to page 3, we'll figure out that to get the illustration before the /\x2f first one on page 3, we need to load page 2. /\x2f /\x2f One edge case is when the underlying search changes while we're viewing it. For example, /\x2f if we're viewing page 2 with ids [1,2,3,4,5], and when we load page 3 it has ids /\x2f [5,6,7,8,9], that usually means new entries were added to the start since we started. /\x2f We don't want the same ID to occur twice, so we'll detect if this happens, and clear /\x2f all other pages. That way, we'll reload the previous pages with the updated data if /\x2f we navigate back to them. import { helpers } from '/vview/misc/helpers.js'; export default class IllustIdList { constructor() { this.mediaIdsByPage = new Map(); }; getAllMediaIds() { /\x2f Make a list of all IDs we already have. let allIds = []; for(let [page, ids] of this.mediaIdsByPage) allIds = allIds.concat(ids); return allIds; } get anyPagesLoaded() { return this.mediaIdsByPage.size != 0; } getLowestLoadedPage() { /\x2f Give a default in case mediaIdsByPage is empty, so we don't return infinity. return Math.min(999999, ...this.mediaIdsByPage.keys()); } getHighestLoadedPage() { return Math.max(0, ...this.mediaIdsByPage.keys()); } /\x2f Add a page of results. /\x2f /\x2f If the page cache has been invalidated, return false. This happens if we think the /\x2f results have changed too much for us to reconcile it. addPage(page, mediaIds, { /\x2f If mediaIds is empty, that normally means we're past the end of the results, so we /\x2f don't add the page. That way, canLoadPage() will return false for future pages. /\x2f If allowEmpty is true, allow adding empty pages. This is used when we have an empty /\x2f page but we know we're not actually at the end. allowEmpty=false, }={}) { /\x2f Sanity check: for(let mediaId of mediaIds) if(mediaId == null) console.warn("Null illust_id added"); if(this.mediaIdsByPage.has(page)) { console.warn("Page", page, "was already loaded"); return true; } /\x2f Make a list of all IDs we already have. let allIllusts = this.getAllMediaIds(); /\x2f For fast-moving pages like new_illust.php, we'll very often get a few entries at the /\x2f start of page 2 that were at the end of page 1 when we requested it, because new posts /\x2f have been added to page 1 that we haven't seen. Remove any duplicate IDs. let idsToRemove = []; for(let newId of mediaIds) { if(allIllusts.indexOf(newId) != -1) idsToRemove.push(newId); } if(idsToRemove.length > 0) console.log("Removing duplicate illustration IDs:", idsToRemove.join(", ")); mediaIds = mediaIds.slice(); for(let newId of idsToRemove) { let idx = mediaIds.indexOf(newId); mediaIds.splice(idx, 1); } /\x2f If there's nothing on this page, don't add it, so this doesn't increase /\x2f getHighestLoadedPage(). if(!allowEmpty && mediaIds.length == 0) return; this.mediaIdsByPage.set(page, mediaIds); }; /\x2f Return the page number mediaId is on and the index within the page. /\x2f /\x2f If checkFirstPage is true and mediaId isn't in the list, try the first page /\x2f of mediaId too, so if we're looking for page 3 of a manga post and the data /\x2f source only contains the first page, we'll use that. getPageForMediaId(mediaId, { checkFirstPage=true }={}) { for(let [page, ids] of this.mediaIdsByPage) { let idx = ids.indexOf(mediaId); if(idx != -1) return { page, idx, mediaId }; } if(!checkFirstPage) return { }; /\x2f Try the first page. mediaId = helpers.mediaId.getMediaIdFirstPage(mediaId); for(let [page, ids] of this.mediaIdsByPage) { let idx = ids.indexOf(mediaId); if(ids.indexOf(mediaId) != -1) return { page, idx, mediaId }; } return { }; }; /\x2f Return the next or previous illustration. If we don't have that page, return null. /\x2f /\x2f This only returns illustrations, skipping over any special entries like user:12345. /\x2f If illust_id is null, start at the first loaded illustration. getNeighboringMediaId(mediaId, next, options={}) { for(let i = 0; i < 100; ++i) /\x2f sanity limit { mediaId = this._getNeighboringMediaIdInternal(mediaId, next, options); if(mediaId == null) return null; /\x2f If it's not an illustration, keep looking. let { type } = helpers.mediaId.parse(mediaId); if(type == "illust" || type == "file") return mediaId; } return null; } /\x2f The actual logic for getNeighboringMediaId, except for skipping entries. /\x2f /\x2f manga tells us how to handle manga pages: /\x2f - "normal": Navigate manga pages normally. /\x2f - "skip-to-first": Skip past manga pages, and always go to the first page of the /\x2f next or previous image. /\x2f - "skip-past": Skip past manga pages. If we're navigating backwards, go to the /\x2f last page of the previous image, like we would normally. _getNeighboringMediaIdInternal(mediaId, next, { manga='normal' }={}) { console.assert(manga == 'normal' || manga == 'skip-to-first' || manga == 'skip-past'); if(mediaId == null) return this.getFirstId(); /\x2f If we're navigating forwards and we're not skipping manga pages, grab media info to /\x2f get the page count to see if we're at the end. let id = helpers.mediaId.parse(mediaId); if(id.type == "illust" && manga == 'normal') { /\x2f If we're navigating backwards and we're past page 1, just go to the previous page. if(!next && id.page > 0) { id.page--; return helpers.mediaId.encodeMediaId(id); } /\x2f If we're navigating forwards, grab illust data to see if we can navigate to the /\x2f next page. if(next) { let info = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }); if(info == null) { /\x2f This can happen if we're viewing a deleted image, which has no illust info. console.log("Thumbnail info missing for", mediaId); } else { let [oldIllustId, oldPage] = helpers.mediaId.toIllustIdAndPage(mediaId); if(oldPage < info.pageCount - 1) { /\x2f There are more pages, so just navigate to the next page. id.page++; return helpers.mediaId.encodeMediaId(id); } } } } let { page, idx } = this.getPageForMediaId(mediaId); if(page == null) return null; /\x2f Find the next or previous page that isn't empty, skipping over empty pages. let newMediaId = null; while(newMediaId == null) { let ids = this.mediaIdsByPage.get(page); let newIdx = idx + (next? +1:-1); if(newIdx >= 0 && newIdx < ids.length) { /\x2f Navigate to the next or previous image on the same page. newMediaId = ids[newIdx]; break; } if(next) { /\x2f Get the first illustration on the next page, or null if that page isn't loaded. page++; ids = this.mediaIdsByPage.get(page); if(ids == null) return null; newMediaId = ids[0]; } else { /\x2f Get the last illustration on the previous page, or null if that page isn't loaded. page--; ids = this.mediaIdsByPage.get(page); if(ids == null) return null; newMediaId = ids[ids.length-1]; } } /\x2f If we're navigating backwards and we're not in skip-to-first mode, get the last page on newMediaId. if(!next && manga != 'skip-to-first' && helpers.mediaId.parse(newMediaId).type == "illust") { let info = ppixiv.mediaCache.getMediaInfoSync(newMediaId, { full: false }); if(info == null) { console.log("Thumbnail info missing for", mediaId); return null; } newMediaId = helpers.mediaId.getMediaIdForPage(newMediaId, info.pageCount - 1); } return newMediaId; }; /\x2f Return the first ID, or null if we don't have any. getFirstId() { if(this.mediaIdsByPage.size == 0) return null; let firstPage = this.getLowestLoadedPage(); return this.mediaIdsByPage.get(firstPage)[0]; } /\x2f Return the last ID, or null if we don't have any. getLastId() { if(this.mediaIdsByPage.size == 0) return null; let lastPage = this.getHighestLoadedPage(); let ids = this.mediaIdsByPage.get(lastPage); return ids[ids.length-1]; } /\x2f Return true if the given page is loaded. isPageLoaded(page) { return this.mediaIdsByPage.has(page); } }; /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/illust-id-list.js `), "/vview/sites/site.js": loadBlob("application/javascript", `import { helpers } from '/vview/misc/helpers.js'; /\x2f Each site we support has a singleton Site class. We currently only support /\x2f using a single site (the site we're on) and only create its singleton. export class Site { constructor() { this.dataSourcesByUrl = {}; } /\x2f Run initial setup. Return false if initialization failed and we should stop. async init() { return true; } /\x2f This is called early in initialization. If we're running natively and the URL is /\x2f empty, navigate to a default directory, so we don't start off on an empty page /\x2f every time. If we're on Pixiv, make sure we're on a supported page. async setInitialUrl() { } /\x2f Return the data source for a URL, or null if the page isn't supported. getDataSourceForUrl(url) { return null; } /\x2f Create the data source for a given URL. /\x2f /\x2f If we've already created a data source for this URL, the same one will be /\x2f returned. createDataSourceForUrl(url, { /\x2f If force is true, we'll always create a new data source, replacing any /\x2f previously created one. force=false, /\x2f If startAtBeginning is true, the data source page number in url will be /\x2f ignored, returning to page 1. This only matters for data sources that support /\x2f a start page. startAtBeginning=false, }={}) { let args = new helpers.args(url); let dataSourceClass = this.getDataSourceForUrl(url); if(dataSourceClass == null) { console.error("Unexpected path:", url.pathname); return; } /\x2f Canonicalize the URL to see if we already have a data source for this URL. We only /\x2f keep one data source around for each canonical URL (eg. search filters). let canonicalUrl = helpers.getCanonicalUrl(url, { startAtBeginning: true }).url.toString(); let oldDataSource = this.dataSourcesByUrl[canonicalUrl]; if(!force && oldDataSource != null) { /\x2f console.log("Reusing data source for", url.toString()); /\x2f If the URL has a page number in it, only return it if this data source can load the /\x2f page the caller wants. If we have a data source that starts at page 10 and the caller /\x2f wants page 1, the data source probably won't be able to load it since pages are always /\x2f contiguous. let page = oldDataSource.getStartPage(args); if(!oldDataSource.canLoadPage(page)) console.log(\`Not using cached data source because it can't load page \${page}\`); else return oldDataSource; } /\x2f The search page isn't part of the canonical URL, but keep it in the URL we create /\x2f the data source with, so it starts at the current page. let baseUrl = helpers.getCanonicalUrl(url, { startAtBeginning }).url.toString(); let dataSource = new dataSourceClass({ url: baseUrl }); this.dataSourcesByUrl[canonicalUrl] = dataSource; return dataSource; } /\x2f If we have the given data source cached, discard it, so it'll be recreated /\x2f the next time it's used. discardDataSource(dataSource) { let urlsToRemove = []; for(let url in this.dataSourcesByUrl) { if(this.dataSourcesByUrl[url] === dataSource) urlsToRemove.push(url); } for(let url of urlsToRemove) delete this.dataSourcesByUrl[url]; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/site.js `), "/vview/sites/native/site-native.js": loadBlob("application/javascript", `import * as Site from '/vview/sites/site.js'; import LocalAPI from '/vview/misc/local-api.js'; import { helpers } from '/vview/misc/helpers.js'; import { VView, VViewSearch } from '/vview/sites/native/data-sources/vview.js'; import VViewSimilar from '/vview/sites/native/data-sources/similar.js'; export default class SiteNative extends Site.Site { async init() { helpers.html.setClass(document.body, "native", ppixiv.native); /\x2f If enabled, cache local info which tells us what we have access to. await LocalAPI.loadLocalInfo(); /\x2f If login is required to do anything, no API calls will succeed. Stop now and /\x2f just redirect to login. This is only for the local API. if(LocalAPI.localInfo.enabled && LocalAPI.localInfo.loginRequired) { LocalAPI.redirectToLogin(); return false; } return true; } getDataSourceForUrl(url) { url = new URL(url); let args = new helpers.args(url); if(args.path == "/similar") return VViewSimilar; let { searchOptions } = LocalAPI.getSearchOptionsForArgs(args); if(searchOptions == null && !LocalAPI.localInfo.bookmark_tag_searches_only) return VView; else return VViewSearch; } async setInitialUrl() { if(document.location.hash != "") return; /\x2f If we're limited to tag searches, we don't view folders. Just set the URL /\x2f to "/". if(LocalAPI.localInfo.bookmark_tag_searches_only) { let args = helpers.args.location; args.hashPath = "/"; helpers.navigate(args, { addToHistory: false, cause: "initial" }); return; } /\x2f Read the folder list. If we have any mounts, navigate to the first one. Otherwise, /\x2f show folder:/ as a fallback. let mediaId = "folder:/"; let result = await ppixiv.mediaCache.localSearch(mediaId); if(result.results.length) mediaId = result.results[0].mediaId; let args = helpers.args.location; LocalAPI.getArgsForId(mediaId, args); helpers.navigate(args, { addToHistory: false, cause: "initial" }); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/native/site-native.js `), "/vview/sites/native/data-sources/similar.js": loadBlob("application/javascript", `import DataSource from '/vview/sites/data-source.js'; import LocalAPI from '/vview/misc/local-api.js'; import { getUrlForMediaId } from '/vview/misc/media-ids.js' import { helpers } from '/vview/misc/helpers.js'; export default class DataSources_VViewSimilar extends DataSource { get name() { return "similar"; } get pageTitle() { return this.getDisplayingText(); } getDisplayingText() { return \`Similar images\`; } get isVView() { return true; } async loadPageInternal(page) { if(page != 1) return; /\x2f We can be given a local path or a URL to an image to search for. let args = new helpers.args(this.url); let path = args.hash.get("search_path"); let url = args.hash.get("search_url"); let result = await LocalAPI.localPostRequest(\`/api/similar/search\`, { path, url, max_results: 10, }); if(!result.success) { ppixiv.message.show("Error reading search: " + result.reason); return result; } /\x2f This is a URL to the original image we're searching for. this.sourceUrl = result.source_url; this.callUpdateListeners(); let mediaIds = []; for(let item of result.results) { /\x2f console.log(item.score); /\x2f Register the results with media_cache. let entry = item.entry; await ppixiv.mediaCache.addFullMediaInfo(entry); mediaIds.push(entry.mediaId); } return { mediaIds }; }; /\x2f We only load one page of results. canLoadPage(page) { return page == 1; } setPageIcon() { helpers.setIcon({vview: true}); } get uiInfo() { let imageUrl = null; let imageLinkUrl = null; if(this.sourceUrl) { imageUrl = this.sourceUrl; /\x2f If this is a search for a local path, link to the image. let args = new helpers.args(this.url); let path = args.hash.get("search_path"); if(path) { let mediaId = helpers.mediaId.encodeMediaId({type: "file", id: path}); let linkArgs = getUrlForMediaId(mediaId); imageLinkUrl = linkArgs; } } return { imageUrl, imageLinkUrl }; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/native/data-sources/similar.js `), "/vview/sites/native/data-sources/vview.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; import DataSource, { PaginateMediaIds, TagDropdownWidget } from '/vview/sites/data-source.js'; import LocalAPI from '/vview/misc/local-api.js'; import { LocalSearchBoxWidget } from '/vview/widgets/local-widgets.js'; import { DropdownMenuOpener } from '/vview/widgets/dropdown.js'; import { helpers } from '/vview/misc/helpers.js'; class VViewBase extends DataSource { get pageTitle() { return this.getDisplayingText(); } get isVView() { return true; } get supportsStartPage() { return true; } get ui() { return UI; } get autoLoadPreviousPages() { return true; } constructor(args) { super(args); this.reachedEnd = false; this.prevPageUuid = null; this.nextPageUuid = null; this.nextPageOffset = null; this.bookmarkTagCounts = null; } async init() { super.init(); this.fetchBookmarkTagCounts(); } /\x2f We set our own start page by looking for the starting ID, so don't pollute the URL /\x2f with a page number that won't be sued. setStartPage(args, page) { } get uiInfo() { let args = new helpers.args(this.url); let mediaId = LocalAPI.getLocalIdFromArgs(args, { getFolder: true }); return { mediaId }; } setPageIcon() { helpers.setIcon({vview: true}); } getDisplayingText() { let args = new helpers.args(this.url); return LocalAPI.getSearchOptionsForArgs(args).title; } /\x2f Put the illust ID in the hash instead of the path. Pixiv doesn't care about this, /\x2f and this avoids sending the user's filenames to their server as 404s. setUrlMediaId(mediaId, args) { LocalAPI.getArgsForId(mediaId, args); } getUrlMediaId(args) { /\x2f If the URL points to a file, return it. If no image is being viewed this will give /\x2f the folder we're in, which shouldn't be returned here. let mediaId = LocalAPI.getLocalIdFromArgs(args); if(mediaId == null || !mediaId.startsWith("file:")) return null; return mediaId; } /\x2f We're doing a bookmark search if the bookmark filter is enabled, or if /\x2f we're restricted to listing tagged bookmarks. get bookmarkSearchActive() { return this.args.hash.has("bookmarks") || LocalAPI.localInfo.bookmark_tag_searches_only; } async fetchBookmarkTagCounts() { if(this.fetchedBookmarkTagCounts) return; this.fetchedBookmarkTagCounts = true; /\x2f We don't need to do this if we're not showing bookmarks. if(!this.bookmarkSearchActive) return; let result = await LocalAPI.localPostRequest(\`/api/bookmark/tags\`); if(!result.success) { console.log("Error fetching bookmark tag counts"); return; } this.bookmarkTagCounts = result.tags; this.callUpdateListeners(); } } /\x2f This data source is used when we have no search and we're viewing a single directory. /\x2f We'll load the whole directory with /ids, and then load media info as we go. export class VView extends VViewBase { get name() { return "vview"; } constructor(url) { super(url); this._allIds = null; } async init({targetMediaId}) { await super.init(); if(this._initialized) return; this._initialized = true; let args = new helpers.args(this.url); let { searchOptions } = LocalAPI.getSearchOptionsForArgs(args); let folderId = LocalAPI.getLocalIdFromArgs(args, { getFolder: true }); console.log("Loading folder contents:", folderId); let order = args.hash.get("order"); let resultIds = await LocalAPI.localPostRequest(\`/api/ids/\${folderId}\`, { ...searchOptions, ids_only: true, order, }); if(!resultIds.success) { ppixiv.message.show("Error reading directory: " + resultIds.reason); return; } this.pages = PaginateMediaIds(resultIds.ids, this.estimatedItemsPerPage); this._allIds = resultIds.ids; /\x2f If a file was present in the URL when we're created, try to start on the page /\x2f containing it, overriding the starting page. this._selectInitialPage(targetMediaId); } /\x2f Return the index into this.pages containing mediaId, or -1 if not found. /\x2f /\x2f Note that the result is an index into this.pages, which is 0-based. getMediaIdPage(mediaId) { if(this.pages == null) return -1; for(let page = 0; page < this.pages.length; ++page) { let mediaIdsOnPage = this.pages[page]; if(mediaIdsOnPage.indexOf(mediaId) != -1) return page; } return -1; } /\x2f Most data sources load a page of results at a time and remember the current page /\x2f in the URL. VView works differently: if we're viewing a directory we get the entire /\x2f directory's IDs at once. We don't store the page in the URL, since we might be /\x2f loaded directly from a file association that wouldn't know which page it'll be. /\x2f /\x2f The default getStartPage expects a page number. Override it so if we're viewing /\x2f an image, we'll return the page it's on. That way if we're viewing an image and /\x2f navigate to the next image, it'll know which page we're on and won't create a new /\x2f data source thinking we're trying to navigate to page 1. getStartPage(args) { if(this.pages == null) return 1; let mediaId = LocalAPI.getLocalIdFromArgs(args); if(mediaId == null) return 1; let page = this.getMediaIdPage(mediaId); if(page != -1) return page + 1; /\x2f 0-based to 1-based return 1; } _selectInitialPage(targetMediaId) { if(targetMediaId == null) return; let page = this.getMediaIdPage(targetMediaId); if(page == -1) return; /\x2f If the new initial page couldn't normally be loaded, reset our loaded pages and /\x2f start over. let newInitialPage = page + 1; let needsReset = !this.canLoadPage(newInitialPage); this.initialPage = newInitialPage; console.log(\`Start on page \${this.initialPage}, reset: \${needsReset}\`); if(needsReset) this._resetLoadedPages(); } /\x2f If we've loaded all pages, we can display the file index as a page number. getPageTextForMediaId(mediaId) { if(this._allIds == null) return null; let idx = this._allIds.indexOf(mediaId); if(idx == -1) return null; return \`Page \${idx+1}/\${this._allIds.length}\`; } async loadPageInternal(page) { let mediaIds = this.pages[page-1] || []; /\x2f Load info for these images before returning them. await ppixiv.mediaCache.batchGetMediaInfoPartial(mediaIds); return { mediaIds }; } } export class VViewSearch extends VViewBase { get name() { return "vview-search"; } async loadPageInternal(page) { /\x2f If the last result was at the end, stop. if(this.reachedEnd) return; /\x2f We should only be called in one of three ways: a start page (any page, but only if we have /\x2f nothing loaded), or a page at the start or end of pages we've already loaded. Figure out which /\x2f one this is. "page" is set to result.next of the last page to load the next page, or result.prev /\x2f of the first loaded page to load the previous page. let lowestPage = this.idList.getLowestLoadedPage(); let highestPage = this.idList.getHighestLoadedPage(); let pageUuid = null; let loadingDirection; if(page == lowestPage - 1) { /\x2f Load the previous page. pageUuid = this.prevPageUuid; loadingDirection = "backwards"; } else if(page == highestPage + 1) { /\x2f Load the next page. pageUuid = this.nextPageUuid; loadingDirection = "forwards"; } else if(this.nextPageOffset == null) { loadingDirection = "initial"; } else { /\x2f This isn't our start page, and it doesn't match up with our next or previous page. console.error(\`Loaded unexpected page \${page} (\${lowestPage}...\${highestPage})\`); return; } if(this.nextPageOffset == null) { /\x2f We haven't loaded any pages yet, so we can't resume the search in-place. Set next_page_offset /\x2f to the approximate offset to skip to this page number. this.nextPageOffset = this.estimatedItemsPerPage * (page-1); } let args = new helpers.args(this.url); let { searchOptions } = LocalAPI.getSearchOptionsForArgs(args); let folderId = LocalAPI.getLocalIdFromArgs(args, { getFolder: true }); let order = args.hash.get("order"); /\x2f Note that this registers the results with MediaCache automatically. let result = await ppixiv.mediaCache.localSearch(folderId, { ...searchOptions, order: order, /\x2f If we have a next_page_uuid, use it to load the next page. page: pageUuid, limit: this.estimatedItemsPerPage, /\x2f This is used to approximately resume the search if next_page_uuid has expired. skip: this.nextPageOffset, }); if(!result.success) { ppixiv.message.show("Error reading directory: " + result.reason); return result; } /\x2f Update the next and previous page IDs. If we're loading backwards, always update /\x2f the previous page. If we're loading forwards, always update the next page. If /\x2f either of these are null, update both. if(loadingDirection == "backwards" || loadingDirection == "initial") this.prevPageUuid = result.pages.prev; if(loadingDirection == "forwards" || loadingDirection == "initial") this.nextPageUuid = result.pages.next; this.nextPageOffset = result.next_offset; /\x2f If next is null, we've reached the end of the results. if(result.pages.next == null) this.reachedEnd = true; let mediaIds = []; for(let thumb of result.results) mediaIds.push(thumb.mediaId); return { mediaIds }; }; /\x2f Override canLoadPage. If we've already loaded a page, we've cached the next /\x2f and previous page UUIDs and we don't want to load anything else, even if the first /\x2f page we loaded had no results. canLoadPage(page) { if(page < 1) return false; /\x2f next_page_offset is null if we haven't tried to load anything yet. if(this.nextPageOffset == null) return true; /\x2f If we've loaded pages 5-6, we can load anything between pages 4 and 7. let lowestPage = this.idList.getLowestLoadedPage(); let highestPage = this.idList.getHighestLoadedPage(); return page >= lowestPage-1 && page <= highestPage+1; } } class UI extends Widget { constructor({dataSource, ...options}) { super({ ...options, dataSource, template: \`
\${ helpers.createBoxLink({label: "Bookmarks", popup: "Show bookmarks", dataType: "local-bookmarks-only" }) }
\${ helpers.createBoxLink({label: "Tags", icon: "ppixiv:tag", classes: ["bookmark-tags-button"] }) }
\${ helpers.createBoxLink({ label: "Type", classes: ["file-type-button"] }) } \${ helpers.createBoxLink({ label: "Aspect ratio", classes: ["aspect-ratio-button"] }) } \${ helpers.createBoxLink({ label: "Image size", classes: ["image-size-button"] }) } \${ helpers.createBoxLink({ label: "Order", classes: ["sort-button"] }) } \${ helpers.createBoxLink({ label: "Reset", popup: "Clear all search options", classes: ["clear-local-search"] }) }
\`}); this.dataSource = dataSource; /\x2f The search history dropdown for local searches. new LocalSearchBoxWidget({ container: this.querySelector(".tag-search-box-container") }); dataSource.setupDropdown(this.querySelector(".file-type-button"), [{ createOptions: { label: "All", dataType: "local-type-all", dataset: { default: "1"} }, setupOptions: { fields: {"#type": null} }, }, { createOptions: { label: "Videos", dataType: "local-type-videos" }, setupOptions: { fields: {"#type": "videos"} }, }, { createOptions: { label: "Images", dataType: "local-type-images" }, setupOptions: { fields: {"#type": "images"} }, }]); dataSource.setupDropdown(this.querySelector(".aspect-ratio-button"), [{ createOptions: { label: "All", dataType: "local-aspect-ratio-all", dataset: { default: "1"} }, setupOptions: { fields: {"#aspect-ratio": null} }, }, { createOptions: { label: "Landscape", dataType: "local-aspect-ratio-landscape" }, setupOptions: { fields: {"#aspect-ratio": \`3:2...\`} }, }, { createOptions: { label: "Portrait", dataType: "local-aspect-ratio-portrait" }, setupOptions: { fields: {"#aspect-ratio": \`...2:3\`} }, }]); dataSource.setupDropdown(this.querySelector(".image-size-button"), [{ createOptions: { label: "All", dataset: { default: "1"} }, setupOptions: { fields: {"#pixels": null} }, }, { createOptions: { label: "High-res" }, setupOptions: { fields: {"#pixels": "4000000..."} }, }, { createOptions: { label: "Medium-res" }, setupOptions: { fields: {"#pixels": "1000000...3999999"} }, }, { createOptions: { label: "Low-res" }, setupOptions: { fields: {"#pixels": "...999999"} }, }]); dataSource.setupDropdown(this.querySelector(".sort-button"), [{ createOptions: { label: "Name", dataset: { default: "1"} }, setupOptions: { fields: {"#order": null} }, }, { createOptions: { label: "Name (inverse)" }, setupOptions: { fields: {"#order": "-normal"} }, }, { createOptions: { label: "Newest" }, setupOptions: { fields: {"#order": "-ctime"} }, }, { createOptions: { label: "Oldest" }, setupOptions: { fields: {"#order": "ctime"} }, }, { createOptions: { label: "New bookmarks" }, setupOptions: { fields: {"#order": "bookmarked-at"}, /\x2f If a bookmark sort is selected, also enable viewing bookmarks. adjustUrl: (args) => args.hash.set("bookmarks", 1), }, }, { createOptions: { label: "Old bookmarks" }, setupOptions: { fields: {"#order": "-bookmarked-at"}, adjustUrl: (args) => args.hash.set("bookmarks", 1), }, }, { createOptions: { label: "Shuffle", icon: "shuffle" }, setupOptions: { fields: {"#order": "shuffle"}, toggle: true }, }]); class BookmarkTagDropdown extends TagDropdownWidget { refreshTags() { /\x2f Clear the tag list. for(let tag of this.root.querySelectorAll(".following-tag")) tag.remove(); /\x2f Stop if we don't have the tag list yet. if(this.dataSource.bookmarkTagCounts == null) return; this.addTagLink(null); /\x2f All this.addTagLink(""); /\x2f Uncategorized let allTags = Object.keys(this.dataSource.bookmarkTagCounts); allTags.sort((lhs, rhs) => lhs.toLowerCase().localeCompare(rhs.toLowerCase())); for(let tag of allTags) { /\x2f Skip uncategorized, which is always placed at the beginning. if(tag == "") continue; if(this.dataSource.bookmarkTagCounts[tag] == 0) continue; this.addTagLink(tag); } } addTagLink(tag) { let tagCount = this.dataSource.bookmarkTagCounts[tag]; let tagName = tag; if(tagName == null) tagName = "All bookmarks"; else if(tagName == "") tagName = "Untagged"; /\x2f Show the bookmark count in the popup. let popup = null; if(tagCount != null) popup = tagCount + (tagCount == 1? " bookmark":" bookmarks"); let a = helpers.createBoxLink({ label: tagName, classes: ["following-tag"], dataType: "following-tag", popup, link: "#", asElement: true, }); if(tagName == "All bookmarks") a.dataset.default = 1; this.dataSource.setItem(a, { fields: {"#bookmark-tag": tag}, }); this.root.appendChild(a); } } this.tagDropdownOpener = new DropdownMenuOpener({ button: this.querySelector(".bookmark-tags-button"), createDropdown: ({...options}) => new BookmarkTagDropdown({ dataSource, ...options }), }); /\x2f Hide the bookmark box if we're not showing bookmarks. this.querySelector(".local-bookmark-tags-box").hidden = !dataSource.bookmarkSearchActive; dataSource.addEventListener("updated", () => { /\x2f Refresh the displayed label in case we didn't have it when we created the widget. this.tagDropdownOpener.setButtonPopupHighlight(); }, this._signal); let clearLocalSearchButton = this.querySelector(".clear-local-search"); clearLocalSearchButton.addEventListener("click", (e) => { /\x2f Get the URL for the current folder and set it to a new URL, so it removes search /\x2f parameters. let mediaId = LocalAPI.getLocalIdFromArgs(dataSource.args, { getFolder: true }); let args = new helpers.args("/", ppixiv.plocation); LocalAPI.getArgsForId(mediaId, args); helpers.navigate(args); }); let searchActive = LocalAPI.getSearchOptionsForArgs(dataSource.args).searchOptions != null; if(dataSource.args.hash.has("order")) searchActive = true; helpers.html.setClass(clearLocalSearchButton, "disabled", !searchActive); dataSource.setItem(this.root, { type: "local-bookmarks-only", fields: {"#bookmarks": "1"}, toggle: true, adjustUrl: (args) => { /\x2f If the button is exiting bookmarks, remove bookmark-tag too. if(!args.hash.has("bookmarks")) args.hash.delete("bookmark-tag"); } }); /\x2f If we're only allowed to do bookmark searches, hide the bookmark search button. this.querySelector('[data-type="local-bookmarks-only"]').hidden = LocalAPI.localInfo.bookmark_tag_searches_only; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/native/data-sources/vview.js `), "/vview/sites/pixiv/site-pixiv.js": loadBlob("application/javascript", `import { helpers } from '/vview/misc/helpers.js'; import * as Site from '/vview/sites/site.js'; import Discovery from '/vview/sites/pixiv/data-sources/discover-illusts.js'; import DiscoverUsers from '/vview/sites/pixiv/data-sources/discover-users.js'; import SimilarIllusts from '/vview/sites/pixiv/data-sources/similar-illusts.js'; import Rankings from '/vview/sites/pixiv/data-sources/rankings.js'; import Artist from '/vview/sites/pixiv/data-sources/artist.js'; import Illust from '/vview/sites/pixiv/data-sources/illust.js'; import FollowedUsers from '/vview/sites/pixiv/data-sources/followed-users.js'; import MangaPages from '/vview/sites/pixiv/data-sources/manga-pages.js'; import Series from '/vview/sites/pixiv/data-sources/series.js'; import SearchIllusts from '/vview/sites/pixiv/data-sources/search-illusts.js'; import NewPostsByFollowing from '/vview/sites/pixiv/data-sources/new-posts-by-following.js'; import NewPostsByEveryone from '/vview/sites/pixiv/data-sources/new-posts-by-everyone.js'; import RelatedFavorites from '/vview/sites/pixiv/data-sources/related-favorites.js'; import SearchUsers from '/vview/sites/pixiv/data-sources/search-users.js'; import CompletedRequests from '/vview/sites/pixiv/data-sources/completed-requests.js'; import EditedImages from '/vview/sites/pixiv/data-sources/edited-images.js'; import { Bookmarks, BookmarksMerged } from '/vview/sites/pixiv/data-sources/bookmarks.js'; let allDataSources = { Discovery, SimilarIllusts, DiscoverUsers, Rankings, Artist, Illust, MangaPages, Series, Bookmarks, BookmarksMerged, NewPostsByEveryone, NewPostsByFollowing, SearchIllusts, FollowedUsers, RelatedFavorites, SearchUsers, CompletedRequests, EditedImages, }; export default class SitePixiv extends Site.Site { async init() { helpers.html.setClass(document.body, "pixiv", true); /\x2f Pixiv scripts that use meta-global-data remove the element from the page after /\x2f it's parsed for some reason. Try to get global info from document, and if it's /\x2f not there, re-fetch the page to get it. if(!this._loadGlobalInfoFromDocument(document)) { if(!await this._loadGlobalDataAsync()) return; } /\x2f Remove Pixiv's content from the page and move it into a dummy document. let html = document.createElement("document"); if(!ppixiv.native) { helpers.html.moveChildren(document.head, html); helpers.html.moveChildren(document.body, html); } /\x2f Check that we found pixivTests. if(!ppixiv.native && ppixiv.pixivInfo?.pixivTests == null) console.log("pixivTests not available"); /\x2f Set the .premium class on body if this is a premium account, to display features /\x2f that only work with premium. helpers.html.setClass(document.body, "premium", ppixiv.pixivInfo.premium); /\x2f These are used to hide buttons that the user has disabled. helpers.html.setClass(document.body, "hide-r18", !ppixiv.pixivInfo.include_r18); helpers.html.setClass(document.body, "hide-r18g", !ppixiv.pixivInfo.include_r18g); /\x2f See if the page has preload data. This sometimes contains illust and user info /\x2f that the page will display, which lets us avoid making a separate API call for it. let preload = document.querySelector("#meta-preload-data"); if(preload != null) { preload = JSON.parse(preload.getAttribute("content")); for(let preloadUserId in preload.user) ppixiv.userCache.addUserData(preload.user[preloadUserId]); for(let preloadMediaId in preload.illust) ppixiv.mediaCache.addPixivFullMediaInfo(preload.illust[preloadMediaId]); } return true; } /\x2f Load Pixiv's global info from doc. This can be the document, or a copy of the /\x2f document that we fetched separately. Return true on success. _loadGlobalInfoFromDocument(doc) { /\x2f When running locally, just load stub data, since this isn't used. if(ppixiv.native) { this._initGlobalData({ csrfToken: "no token", userId: "no id" , premium: true, mutes: [], contentMode: 2, }); return true; } /\x2f Stop if we already have this. if(ppixiv.pixivInfo) return true; /\x2f #meta-pixiv-tests seems to contain info about features/misfeatures that are only enabled /\x2f on some users. Grab this if it's available, so we can tell if recaptcha_follow_user is /\x2f enabled for this user. This can also come from script#__NEXT_DATA__ below. let pixivTests = null; let pixivTestsElement = doc.querySelector("#meta-pixiv-tests"); if(pixivTestsElement) pixivTests = JSON.parse(pixivTestsElement.getAttribute("content")); if(ppixiv.mobile) { /\x2f On mobile we can get most of this from meta#init-config. However, it doesn't include /\x2f mutes, and we'd still need to wait for a /touch/ajax/user/self/status API call to get those. /\x2f Since it doesn't actually save us from having to wait for an API call, we just let it /\x2f use the regular fallback. let initConfig = document.querySelector("meta#init-config"); if(initConfig) { let config = JSON.parse(initConfig.getAttribute("content")); this._initGlobalData({ pixivTests, csrfToken: config["pixiv.context.postKey"], userId: config["pixiv.user.id"], premium: config["pixiv.user.premium"] == "1", mutes: null, /\x2f mutes missing on mobile contentMode: config["pixiv.user.x_restrict"], recaptchaKey: config["pixiv.context.recaptchaEnterpriseScoreSiteKey"], /\x2f We'd also need to make a user/self/status call to get this. This is only used to /\x2f show or hide the search filter and the actual filtering happens server-side, so /\x2f for now we don't bother. hideAiWorks: false, }); return true; } } /\x2f This format is used on at least /new_illust.php. let globalData = doc.querySelector("#meta-global-data"); if(globalData != null) globalData = JSON.parse(globalData.getAttribute("content")); if(globalData == null) { /\x2f /request has its own special tag. let nextData = doc.querySelector("script#__NEXT_DATA__"); if(nextData != null) { nextData = JSON.parse(nextData.innerText); globalData = nextData.props.pageProps; pixivTests = globalData.activeABTests; } } if(globalData == null) return false; /\x2f Discard this if it doesn't have login info. if(globalData.userData == null) return false; this._initGlobalData({ csrfToken: globalData.token, userId: globalData.userData.id , premium: globalData.userData.premium, mutes: globalData.mute, hideAiWorks: globalData.userData.hideAiWorks, contentMode: globalData.userData.xRestrict, pixivTests, recaptchaKey: globalData?.miscData?.grecaptcha?.recaptchaEnterpriseScoreSiteKey, }); return true; } /\x2f This is called if we're on a page that didn't give us init data. We'll load it from /\x2f a page that does. async _loadGlobalDataAsync() { console.assert(!ppixiv.native); console.log("Reloading page to get init data"); /\x2f Use the requests page to get init data. This is handy since it works even if the /\x2f site thinks we're mobile, so it still works if we're testing with DevTools set to /\x2f mobile mode. let result = await helpers.pixivRequest.fetchDocument("/request"); console.log("Finished loading init data"); if(this._loadGlobalInfoFromDocument(result)) return true; /\x2f The user is probably not logged in. If this happens on this code path, we /\x2f can't restore the page. console.log("Couldn't find context data. Are we logged in?"); ppixiv.app.showLoggedOutMessage(true); /\x2f Redirect to no-ppixiv, to reload the page disabled so we don't leave the user /\x2f on a blank page. If this is a page where Pixiv itself requires a login (which /\x2f is most of them), the initial page request will redirect to the login page before /\x2f we launch, but we can get here for a few pages. let disabledUrl = new URL(document.location); if(disabledUrl.hash != "#no-ppixiv") { disabledUrl.hash = "#no-ppixiv"; document.location = disabledUrl.toString(); /\x2f Make sure we reload after changing this. document.location.reload(); } return false; } _initGlobalData({ userId, csrfToken, premium, mutes, hideAiWorks=false, contentMode, pixivTests={}, recaptchaKey=null, }={}) { if(mutes) { let pixivMutedTags = []; let pixivMutedUserIds = []; for(let mute of mutes) { if(mute.type == 0) pixivMutedTags.push(mute.value); else if(mute.type == 1) pixivMutedUserIds.push(mute.value); } ppixiv.muting.setMutes({pixivMutedTags, pixivMutedUserIds}); } else { /\x2f This page doesn't tell us the user's mutes. Load from cache if possible, and request /\x2f the mute list from the server. This normally only happens on mobile. console.assert(ppixiv.mobile); ppixiv.muting.loadCachedMutes(); ppixiv.muting.fetchMutes(); } ppixiv.pixivInfo = { userId, include_r18: contentMode >= 1, include_r18g: contentMode >= 2, premium, hideAiWorks, pixivTests, recaptchaKey, }; /\x2f Give pixivRequest the CSRF token and user ID. helpers.pixivRequest.setPixivRequestInfo({csrfToken, userId}); }; async setInitialUrl() { let args = helpers.args.location; /\x2f If we're active but we're on a page that isn't directly supported, redirect to /\x2f a supported page. This should be synced with Startup.refresh_disabled_ui. if(this.getDataSourceForUrl(ppixiv.plocation) == null) args = new helpers.args("/ranking.php?mode=daily#ppixiv"); /\x2f If the URL hash doesn't start with #ppixiv, the page was loaded with the base Pixiv /\x2f URL, and we're active by default. Add #ppixiv to the URL. If we don't do this, we'll /\x2f still work, but none of the URLs we create will have #ppixiv, so we won't handle navigation /\x2f directly and the page will reload on every click. Do this before we create any of our /\x2f UI, so our links inherit the hash. if(!helpers.args.isPPixivUrl(args.url)) args.hash = "#ppixiv"; helpers.navigate(args, { addToHistory: false, cause: "initial" }); } getDataSourceForUrl(url) { url = new URL(url); url = helpers.pixiv.getUrlWithoutLanguage(url); let args = new helpers.args(url); args = new helpers.args(url); let parts = url.pathname.split("/"); let firstPathSegment = parts[1]; if(firstPathSegment == "artworks") { if(args.hash.get("manga")) return allDataSources.MangaPages; else return allDataSources.Illust; } else if(firstPathSegment == "user" && parts[3] == "series") return allDataSources.Series; else if(firstPathSegment == "users") { /\x2f This is one of: /\x2f /\x2f /users/12345 /\x2f /users/12345/artworks /\x2f /users/12345/illustrations /\x2f /users/12345/manga /\x2f /users/12345/bookmarks /\x2f /users/12345/following /\x2f /\x2f All of these except for bookmarks are handled by allDataSources.Artist. let mode = helpers.strings.getPathPart(url, 2); if(mode == "following") return allDataSources.FollowedUsers; if(mode != "bookmarks") return allDataSources.Artist; /\x2f If show-all=0 isn't in the hash, and we're not viewing someone else's bookmarks, /\x2f we're viewing all bookmarks, so use allDataSources.BookmarksMerged. Otherwise, /\x2f use allDataSources.bookmarks. let userId = helpers.strings.getPathPart(url, 1); if(userId == null) userId = ppixiv.pixivInfo.userId; let viewingOwnBookmarks = userId == ppixiv.pixivInfo.userId; let bothPublicAndPrivate = viewingOwnBookmarks && args.hash.get("show-all") != "0"; return bothPublicAndPrivate? allDataSources.BookmarksMerged:allDataSources.Bookmarks; } else if(url.pathname == "/new_illust.php" || url.pathname == "/new_illust_r18.php") return allDataSources.NewPostsByEveryone; else if(url.pathname == "/bookmark_new_illust.php" || url.pathname == "/bookmark_new_illust_r18.php") return allDataSources.NewPostsByFollowing; else if(firstPathSegment == "tags") return allDataSources.SearchIllusts; else if(url.pathname == "/discovery") return allDataSources.Discovery; else if(url.pathname == "/discovery/users") return allDataSources.DiscoverUsers; else if(url.pathname == "/bookmark_detail.php") { /\x2f If we've added "recommendations" to the hash info, this was a recommendations link. if(args.hash.get("recommendations")) return allDataSources.SimilarIllusts; else return allDataSources.RelatedFavorites; } else if(url.pathname == "/ranking.php") return allDataSources.Rankings; else if(url.pathname == "/search_user.php") return allDataSources.SearchUsers; else if(url.pathname.startsWith("/request/complete")) return allDataSources.CompletedRequests; else if(firstPathSegment == "") { /\x2f Data sources that don't have a corresponding Pixiv page: if(args.hashPath == "/edits") return allDataSources.EditedImages; else return null; } else return null; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/site-pixiv.js `), "/vview/sites/pixiv/data-sources/artist.js": loadBlob("application/javascript", `/\x2f - User illustrations /\x2f /\x2f /users/# /\x2f /users/#/artworks /\x2f /users/#/illustrations /\x2f /users/#/manga /\x2f /\x2f We prefer to link to the /artworks page, but we handle /users/# as well. import DataSource, { PaginateMediaIds, TagDropdownWidget } from '/vview/sites/data-source.js'; import Widget from '/vview/widgets/widget.js'; import SavedSearchTags from '/vview/misc/saved-search-tags.js'; import { DropdownMenuOpener } from '/vview/widgets/dropdown.js'; import { helpers } from '/vview/misc/helpers.js'; export default class DataSources_Artist extends DataSource { get name() { return "artist"; } get ui() { return UI; } constructor(args) { super(args); } get supportsStartPage() { return true; } get viewingUserId() { /\x2f /users/13245 return helpers.strings.getPathPart(this.url, 1); }; /\x2f Return "artworks" (all), "illustrations" or "manga". get viewingType() { /\x2f The URL is one of: /\x2f /\x2f /users/12345 /\x2f /users/12345/artworks /\x2f /users/12345/illustrations /\x2f /users/12345/manga /\x2f /\x2f The top /users/12345 page is the user's profile page, which has the first page of images, but /\x2f instead of having a link to page 2, it only has "See all", which goes to /artworks and shows you /\x2f page 1 again. That's pointless, so we treat the top page as /artworks the same. /illustrations /\x2f and /manga filter those types. let url = helpers.pixiv.getUrlWithoutLanguage(this.url); let parts = url.pathname.split("/"); return parts[3] || "artworks"; } async loadPageInternal(page) { /\x2f We'll load translations for all tags if the tag dropdown is opened, but for now /\x2f just load the translation for the selected tag, so it's available for the button text. let currentTag = this.currentTag; if(currentTag != null) { this.translatedTags = await ppixiv.tagTranslations.getTranslations([currentTag], "en"); this.callUpdateListeners(); } /\x2f Make sure the user info is loaded. Don't wait for this to finish here, so we can start /\x2f other requests in parallel. let userInfoPromise = this._loadUserInfo(); let args = new helpers.args(this.url); let tag = args.query.get("tag") || ""; if(tag == "") { /\x2f If we're not filtering by tag, use the profile/all request. This returns all of /\x2f the user's illust IDs but no thumb data. /\x2f /\x2f We can use the "illustmanga" code path for this by leaving the tag empty, but /\x2f we do it this way since that's what the site does. if(this.pages == null) { let allMediaIds = await this.loadAllResults(); if(args.hash.get("order") == "oldest") allMediaIds.reverse(); this.pages = PaginateMediaIds(allMediaIds, this.estimatedItemsPerPage); } /\x2f Load media info for this page. let mediaIds = this.pages[page-1] || []; await ppixiv.mediaCache.batchGetMediaInfoPartial(mediaIds, { userId: this.viewingUserId }); return { mediaIds }; } else { /\x2f We're filtering by tag. let type = args.query.get("type"); /\x2f For some reason, this API uses a random field in the URL for the type instead of a normal /\x2f query parameter. let typeForUrl = type == null? "illustmanga": type == "illust"?"illusts": "manga"; let requestUrl = \`/ajax/user/\${this.viewingUserId}/\${typeForUrl}/tag\`; let result = await helpers.pixivRequest.get(requestUrl, { tag: tag, offset: (page-1)*48, limit: 48, }); /\x2f Wait until we have user info. Doing this here allows the two API requests to run /\x2f in parallel, but we need the result below. await userInfoPromise; /\x2f This data doesn't have profileImageUrl or userName. That's presumably because it's /\x2f used on user pages which get that from user data, but this seems like more of an /\x2f inconsistency than an optimization. Fill it in for mediaInfo. for(let item of result.body.works) { item.userName = this.userInfo.name; item.profileImageUrl = this.userInfo.imageBig; } let mediaIds = []; for(let illustData of result.body.works) mediaIds.push(helpers.mediaId.fromIllustId(illustData.id)); await ppixiv.mediaCache.addMediaInfosPartial(result.body.works, "normal"); return { mediaIds }; } } async _loadUserInfo() { this.userInfo = await ppixiv.userCache.getUserInfo(this.viewingUserId, { full: true }); this.callUpdateListeners(); } async loadAllResults() { let type = this.viewingType; let result = await helpers.pixivRequest.get(\`/ajax/user/\${this.viewingUserId}/profile/all\`); let illustIds = []; if(type == "artworks" || type == "illustrations") for(let illustId in result.body.illusts) illustIds.push(illustId); if(type == "artworks" || type == "manga") for(let illustId in result.body.manga) illustIds.push(illustId); /\x2f Sort the two sets of IDs back together, putting higher (newer) IDs first. illustIds.sort((lhs, rhs) => parseInt(rhs) - parseInt(lhs)); let mediaIds = []; for(let illustId of illustIds) mediaIds.push(helpers.mediaId.fromIllustId(illustId)); return mediaIds; }; /\x2f If we're filtering a follow tag, return it. Otherwise, return null. get currentTag() { let args = new helpers.args(this.url); return args.query.get("tag"); } get uiInfo() { let headerStripURL = this.userInfo?.background?.url; if(headerStripURL) { headerStripURL = new URL(headerStripURL); helpers.pixiv.adjustImageUrlHostname(headerStripURL); } return { mediaId: \`user:\${this.viewingUserId}\`, userId: this.viewingUserId, /\x2f Override the title on the mobile search menu. mobileTitle: this.userInfo?.name? \`Artist: \${this.userInfo?.name}\`:\`Artist\`, headerStripURL, } } /\x2f This is called when the tag list dropdown is opened. async tagListOpened() { /\x2f Get user info. We probably have this on this.userInfo, but that async load /\x2f might not be finished yet. let userInfo = await ppixiv.userCache.getUserInfo(this.viewingUserId, { full: true }); console.log("Loading tags for user", userInfo.userId); /\x2f Load this artist's common tags. this.postTags = await this.getUserTags(userInfo); /\x2f Mark the tags in this.postTags that the user has searched for recently, so they can be /\x2f marked in the UI. let userTagSearch = SavedSearchTags.getAllUsedTags(); for(let tag of this.postTags) tag.recent = userTagSearch.has(tag.tag); /\x2f Move tags that this artist uses to the top if the user has searched for them recently. this.postTags.sort((lhs, rhs) => { if(rhs.recent != lhs.recent) return rhs.recent - lhs.recent; else return rhs.cnt - lhs.cnt; }); let tags = []; for(let tagInfo of this.postTags) tags.push(tagInfo.tag); this.translatedTags = await ppixiv.tagTranslations.getTranslations(tags, "en"); /\x2f Refresh the tag list now that it's loaded. this.callUpdateListeners(); } async getUserTags(userInfo) { if(userInfo.frequentTags) return Array.from(userInfo.frequentTags); let result = await helpers.pixivRequest.get("/ajax/user/" + userInfo.userId + "/illustmanga/tags", {}); if(result.error) { console.error("Error fetching tags for user " + userInfo.userId + ": " + result.error); userInfo.frequentTags = []; return Array.from(userInfo.frequentTags); } /\x2f Sort most frequent tags first. result.body.sort(function(lhs, rhs) { return rhs.cnt - lhs.cnt; }) /\x2f Store translations. let translations = []; for(let tagInfo of result.body) { if(tagInfo.tag_translation == "") continue; translations.push({ tag: tagInfo.tag, translation: { en: tagInfo.tag_translation, }, }); } ppixiv.tagTranslations.addTranslations(translations); /\x2f Cache the results on the user info. userInfo.frequentTags = result.body; return Array.from(userInfo.frequentTags); } get pageTitle() { if(this.userInfo) return this.userInfo.name; else return "Loading..."; } getDisplayingText() { if(this.userInfo) return this.userInfo.name + "'s Illustrations"; else return "Illustrations"; }; } class UI extends Widget { constructor({dataSource, ...options}) { super({ ...options, template: \`
\${ helpers.createBoxLink({label: "Search mode", classes: ["search-type-button"] }) } \${ helpers.createBoxLink({label: "Newest", classes: ["sort-button"] }) } \${ helpers.createBoxLink({label: "Tags", popup: "Tags", icon: "bookmark", classes: ["member-tags-button"] }) }
\`}); this.dataSource = dataSource; dataSource.addEventListener("updated", () => { /\x2f Refresh the displayed label in case we didn't have it when we created the widget. this.tagDropdown.setButtonPopupHighlight(); }, this._signal); let urlFormat = "users/id/type"; dataSource.setupDropdown(this.querySelector(".search-type-button"), [{ createOptions: { label: "Works", dataset: { default: "1" } }, setupOptions: { urlFormat, fields: {"/type": null}, defaults: {"/type": "artworks"} }, }, { createOptions: { label: "Illusts" }, setupOptions: { urlFormat, fields: {"/type": "illustrations"} }, }, { createOptions: { label: "Manga" }, setupOptions: { urlFormat, fields: {"/type": "manga"} }, }]); /\x2f Sorts are currently only supported when viewing all bookmarks, not when searching /\x2f by tag. let sortButton = this.querySelector(".sort-button"); let tag = dataSource.currentTag; sortButton.hidden = tag != null; dataSource.setupDropdown(sortButton, [{ createOptions: { label: "Newest", dataset: { default: true } }, setupOptions: { fields: {"#order": null}, defaults: {"#order": "newest"} } }, { createOptions: { label: "Oldest" }, setupOptions: { fields: {"#order": "oldest"} } }]); class TagDropdown extends TagDropdownWidget { refreshTags() { /\x2f Refresh the post tag list. helpers.html.removeElements(this.root); if(dataSource.postTags != null) { this.addTagLink({ tag: "All" }); for(let tagInfo of dataSource.postTags || []) this.addTagLink(tagInfo); } else { /\x2f Tags aren't loaded yet. We'll be refreshed after tagListOpened loads tags. /\x2f If a tag is selected, fill in just that tag so the button text works. let span = document.createElement("span"); span.innerText = "Loading..."; this.root.appendChild(span); this.addTagLink({ tag: "All" }); let currentTag = dataSource.currentTag; if(currentTag != null) this.addTagLink({ tag: currentTag }); } } addTagLink(tagInfo) { /\x2f Skip tags with very few posts. This list includes every tag the author /\x2f has ever used, and ends up being pages long with tons of tags that were /\x2f only used once. if(tagInfo.tag != "All" && tagInfo.cnt < 5) return; let tag = tagInfo.tag; let translatedTag = tag; if(dataSource.translatedTags && dataSource.translatedTags[tag]) translatedTag = dataSource.translatedTags[tag]; let classes = ["tag-entry"]; /\x2f If the user has searched for this tag recently, add the recent tag. This is added /\x2f in tagListOpened. if(tagInfo.recent) classes.push("recent"); let a = helpers.createBoxLink({ label: translatedTag, classes, popup: tagInfo?.cnt, link: "#", asElement: true, dataType: "artist-tag", }); dataSource.setItem(a, { fields: {"tag": tag != "All"? tag:null} }); if(tag == "All") a.dataset["default"] = 1; this.root.appendChild(a); }; }; this.tagDropdown = new DropdownMenuOpener({ button: this.querySelector(".member-tags-button"), createDropdown: ({...options}) => new TagDropdown({dataSource, ...options}), onvisibilitychanged: (opener) => { /\x2f Populate the tags dropdown if it's opened, so we don't load user tags for every user page. if(opener.visible); dataSource.tagListOpened(); } }); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/artist.js `), "/vview/sites/pixiv/data-sources/bookmarks.js": loadBlob("application/javascript", `/\x2f bookmark.php /\x2f /users/12345/bookmarks /\x2f /\x2f If id is in the query, we're viewing another user's bookmarks. Otherwise, we're /\x2f viewing our own. /\x2f /\x2f Pixiv currently serves two unrelated pages for this URL, using an API-driven one /\x2f for viewing someone else's bookmarks and a static page for viewing your own. We /\x2f always use the API in either case. /\x2f /\x2f For some reason, Pixiv only allows viewing either public or private bookmarks, /\x2f and has no way to just view all bookmarks. import DataSource, { TagDropdownWidget } from '/vview/sites/data-source.js'; import Widget from '/vview/widgets/widget.js'; import { DropdownMenuOpener } from '/vview/widgets/dropdown.js'; import { helpers } from '/vview/misc/helpers.js'; export class DataSource_BookmarksBase extends DataSource { get name() { return "bookmarks"; } get ui() { return UI; } constructor(args) { super(args); this.bookmarkTagCounts = []; /\x2f The subclass sets this once it knows the number of bookmarks in this search. this.totalBookmarks = -1; } async loadPageInternal(page) { this.fetchBookmarkTagCounts(); /\x2f Load the user's info. We don't need to wait for this to finish. let userInfoPromise = ppixiv.userCache.getUserInfo(this.viewingUserId, { full: true }); userInfoPromise.then((userInfo) => { /\x2f Stop if we were deactivated before this finished. if(!this.active) return; this.userInfo = userInfo; this.callUpdateListeners(); }); return await this.continueLoadingPageInternal(page); }; get supportsStartPage() { /\x2f Disable start pages when we're shuffling pages anyway. return !this.shuffle; } get displayingTag() { let url = helpers.pixiv.getUrlWithoutLanguage(this.url); let parts = url.pathname.split("/"); if(parts.length < 6) return null; /\x2f Replace 未分類 with "" for uncategorized. let tag = decodeURIComponent(parts[5]); if(tag == "未分類") return ""; return tag; } /\x2f If we haven't done so yet, load bookmark tags for this bookmark page. This /\x2f happens in parallel with with page loading. async fetchBookmarkTagCounts() { if(this.fetchedBookmarkTagCounts) return; this.fetchedBookmarkTagCounts = true; /\x2f If we have cached bookmark counts for ourself, load them. if(this.viewingOwnBookmarks() && DataSource_BookmarksBase.cachedBookmarkTagCounts != null) this.loadBookmarkTagCounts(DataSource_BookmarksBase.cachedBookmarkTagCounts); /\x2f Fetch bookmark tags. We can do this in parallel with everything else. let url = "/ajax/user/" + this.viewingUserId + "/illusts/bookmark/tags"; let result = await helpers.pixivRequest.get(url, {}); /\x2f Cache this if we're viewing our own bookmarks, so we can display them while /\x2f navigating bookmarks. We'll still refresh it as each page loads. if(this.viewingOwnBookmarks()) DataSource_BookmarksBase.cachedBookmarkTagCounts = result.body; this.loadBookmarkTagCounts(result.body); } loadBookmarkTagCounts(result) { let publicBookmarks = this.viewingPublic; let privateBookmarks = this.viewingPrivate; /\x2f Reformat the tag list into a format that's easier to work with. let tags = { }; for(let privacy of ["public", "private"]) { let publicTags = privacy == "public"; if((publicTags && !publicBookmarks) || (!publicTags && !privateBookmarks)) continue; let tagCounts = result[privacy]; for(let tagInfo of tagCounts) { let tag = tagInfo.tag; /\x2f Rename "未分類" (uncategorized) to "". if(tag == "未分類") tag = ""; if(tags[tag] == null) tags[tag] = 0; /\x2f Add to the tag count. tags[tag] += tagInfo.cnt; } } /\x2f Fill in totalBookmarks from the tag count. We'll get this from the search API, /\x2f but we can have it here earlier if we're viewing our own bookmarks and /\x2f cachedBookmarkTagCounts is filled in. We can't do this when viewing all bookmarks /\x2f (summing the counts will give the wrong answer whenever multiple tags are used on /\x2f one bookmark). let displayingTag = this.displayingTag; if(displayingTag != null && this.totalBookmarks == -1) { let count = tags[displayingTag]; if(count != null) this.totalBookmarks = count; } /\x2f Sort tags by count, so we can trim just the most used tags. Use the count for the /\x2f display mode we're in. let allTags = Object.keys(tags); allTags.sort((lhs, rhs) => tags[lhs].count - tags[rhs].count); if(!this.viewingOwnBookmarks()) { /\x2f Trim the list when viewing other users. Some users will return thousands of tags. allTags.splice(20); } allTags.sort((lhs, rhs) => lhs.toLowerCase().localeCompare(rhs.toLowerCase())); this.bookmarkTagCounts = {}; for(let tag of allTags) this.bookmarkTagCounts[tag] = tags[tag]; /\x2f Update the UI with the tag list. this.callUpdateListeners(); } /\x2f Get API arguments to query bookmarks. /\x2f /\x2f If forceRest isn't null, it's either "show" (public) or "hide" (private), which /\x2f overrides the search parameters. getBookmarkQueryParams(page, forceRest) { let queryArgs = this.url.searchParams; let rest = queryArgs.get("rest") || "show"; if(forceRest != null) rest = forceRest; let tag = this.displayingTag; if(tag == "") tag = "未分類"; /\x2f Uncategorized else if(tag == null) tag = ""; /\x2f Load 20 results per page, so our page numbers should match the underlying page if /\x2f the UI is disabled. return { tag: tag, offset: (page-1)*this.estimatedItemsPerPage, limit: this.estimatedItemsPerPage, rest: rest, /\x2f public or private (no way to get both) }; } async requestBookmarks(page, rest) { let data = this.getBookmarkQueryParams(page, rest); let url = \`/ajax/user/\${this.viewingUserId}/illusts/bookmarks\`; let result = await helpers.pixivRequest.get(url, data); if(this.viewingOwnBookmarks()) { /\x2f This request includes each bookmark's tags. Register those with MediaCache, /\x2f so the bookmark tag dropdown can display tags more quickly. for(let illust of result.body.works) { let bookmark_id = illust.bookmarkData.id; let tags = result.body.bookmarkTags[bookmark_id] || []; /\x2f illust.id is an int if this image is deleted. Convert it to a string so it's /\x2f like other images. let mediaId = helpers.mediaId.fromIllustId(illust.id.toString()); ppixiv.extraCache.updateCachedBookmarkTags(mediaId, tags); } } /\x2f Store whether there are any results. Do this before filtering deleted images, /\x2f so we know the results weren't empty even if all results on this page are deleted. result.body.empty = result.body.works.length == 0; result.body.works = DataSource_BookmarksBase.filterDeletedImages(result.body.works); return result.body; } /\x2f This is implemented by the subclass to do the main loading. async continueLoadingPageInternal(page) { throw "Not implemented"; } get pageTitle() { if(!this.viewingOwnBookmarks()) { if(this.userInfo) return this.userInfo.name + "'s Bookmarks"; else return "Loading..."; } return "Bookmarks"; } getDisplayingText() { if(!this.viewingOwnBookmarks()) { if(this.userInfo) return this.userInfo.name + "'s Bookmarks"; return "User's Bookmarks"; } let publicBookmarks = this.viewingPublic; let privateBookmarks = this.viewingPrivate; let viewingAll = publicBookmarks && privateBookmarks; let displaying = ""; if(this.totalBookmarks != -1) displaying += this.totalBookmarks + " "; displaying += viewingAll? "Bookmark": privateBookmarks? "Private Bookmark":"Public Bookmark"; /\x2f English-centric pluralization: if(this.totalBookmarks != 1) displaying += "s"; let tag = this.displayingTag; if(tag == "") displaying += \` / untagged\`; else if(tag != null) displaying += \` / \${tag}\`; return displaying; }; /\x2f Return true if we're viewing publig and private bookmarks. These are overridden /\x2f in BookmarksMerged. get viewingPublic() { let args = new helpers.args(this.url); return args.query.get("rest") != "hide"; } get viewingPrivate() { let args = new helpers.args(this.url); return args.query.get("rest") == "hide"; } get uiInfo() { return { userId: this.viewingOwnBookmarks()? null:this.viewingUserId, } } get viewingUserId() { /\x2f /users/13245/bookmarks /\x2f /\x2f This is currently only used for viewing other people's bookmarks. Your own bookmarks are still /\x2f viewed with /bookmark.php with no ID. return helpers.strings.getPathPart(this.url, 1); }; /\x2f Return true if we're viewing our own bookmarks. viewingOwnBookmarks() { return this.viewingUserId == ppixiv.pixivInfo.userId; } /\x2f Don't show bookmark icons for the user's own bookmarks. Every image on that page /\x2f is bookmarked, so it's just a lot of noise. get showBookmarkIcons() { return !this.viewingOwnBookmarks(); } /\x2f Bookmark results include deleted images. These are weird and a bit broken: /\x2f the post ID is an integer instead of a string (which makes more sense but is /\x2f inconsistent with other results) and the data is mostly empty or garbage. /\x2f Check isBookmarkable to filter these out. static filterDeletedImages(images) { let result = []; for(let image of images) { if(!image.isBookmarkable) { console.log("Discarded deleted bookmark " + image.id); continue; } result.push(image); } return result; } } /\x2f Normal bookmark querying. This can only retrieve public or private bookmarks, /\x2f and not both. export class Bookmarks extends DataSource_BookmarksBase { get shuffle() { let args = new helpers.args(this.url); return args.hash.has("shuffle"); } async continueLoadingPageInternal(page) { let pageToLoad = page; if(this.shuffle) { /\x2f We need to know the number of pages in order to shuffle, so load the first page. /\x2f This is why we don't support this for merged bookmark loading: we'd need to load /\x2f both first pages, then both first shuffled pages, so we'd be making four bookmark /\x2f requests all at once. if(this.totalShuffledBookmarks == null) { let result = await this.requestBookmarks(1, null); this.totalShuffledBookmarks = result.total; this.totalPages = Math.ceil(this.totalShuffledBookmarks / this.estimatedItemsPerPage); /\x2f Create a shuffled page list. this.shuffledPages = []; for(let p = 1; p <= this.totalPages; ++p) this.shuffledPages.push(p); helpers.other.shuffleArray(this.shuffledPages); } if(page < this.shuffledPages.length) pageToLoad = this.shuffledPages[page]; } let result = await this.requestBookmarks(pageToLoad, null); let mediaIds = []; for(let illustData of result.works) mediaIds.push(helpers.mediaId.fromIllustId(illustData.id)); /\x2f If we're shuffling, shuffle the individual illustrations too. if(this.shuffle) helpers.other.shuffleArray(mediaIds); await ppixiv.mediaCache.addMediaInfosPartial(result.works, "normal"); /\x2f Remember the total count, for display. this.totalBookmarks = result.total; /\x2f Register the new page of data. If we're shuffling, use the original page number, not the /\x2f shuffled page. return { mediaIds, /\x2f If mediaIds is empty but result.empty is false, we had results in the list but we /\x2f filtered them all out. Set allowEmpty to true in this case so we add the empty page, /\x2f or else it'll look like we're at the end of the results when we know we aren't. allowEmpty: !result.empty, }; } }; /\x2f Merged bookmark querying. This makes queries for both public and private bookmarks, /\x2f and merges them together. export class BookmarksMerged extends DataSource_BookmarksBase { get viewingPublic() { return true; } get viewingPrivate() { return true; } constructor(url) { super(url); this.maxPagePerType = [-1, -1]; /\x2f public, private this.bookmarkMediaIds = [[], []]; /\x2f public, private this.bookmarkTotals = [0, 0]; /\x2f public, private } async continueLoadingPageInternal(page) { /\x2f Request both the public and private bookmarks on the given page. If we've /\x2f already reached the end of either of them, don't send that request. let request1 = this.requestBookmarkType(page, "show"); let request2 = this.requestBookmarkType(page, "hide"); /\x2f Wait for both requests to finish. await Promise.all([request1, request2]); /\x2f Both requests finished. Combine the two lists of illust IDs into a single page /\x2f and register it. let mediaIds = []; for(let i = 0; i < 2; ++i) if(this.bookmarkMediaIds[i] != null && this.bookmarkMediaIds[i][page] != null) mediaIds = mediaIds.concat(this.bookmarkMediaIds[i][page]); /\x2f Combine the two totals. this.totalBookmarks = this.bookmarkTotals[0] + this.bookmarkTotals[1]; return { mediaIds }; } async requestBookmarkType(page, rest) { let isPrivate = rest == "hide"? 1:0; let maxPage = this.maxPagePerType[isPrivate]; if(maxPage != -1 && page > maxPage) { /\x2f We're past the end. console.log("page", page, "beyond", maxPage, rest); return; } let result = await this.requestBookmarks(page, rest); /\x2f Put higher (newer) bookmarks first. result.works.sort(function(lhs, rhs) { return parseInt(rhs.bookmarkData.id) - parseInt(lhs.bookmarkData.id); }); let mediaIds = []; for(let illustData of result.works) mediaIds.push(helpers.mediaId.fromIllustId(illustData.id)); await ppixiv.mediaCache.addMediaInfosPartial(result.works, "normal"); /\x2f If there are no results, remember that this is the last page, so we don't /\x2f make more requests for this type. Use the "empty" flag for this and not /\x2f whether there are any media IDs, in case there were IDs but they're all /\x2f deleted. if(result.empty) { if(this.maxPagePerType[isPrivate] == -1) this.maxPagePerType[isPrivate] = page; else this.maxPagePerType[isPrivate] = Math.min(page, this.maxPagePerType[isPrivate]); /\x2f console.log("max page for", isPrivate? "private":"public", this.maxPagePerType[isPrivate]); } /\x2f Store the IDs. We don't register them here. this.bookmarkMediaIds[isPrivate][page] = mediaIds; /\x2f Remember the total count, for display. this.bookmarkTotals[isPrivate] = result.total; } } class UI extends Widget { constructor({ dataSource, ...options }) { super({ ...options, template: \`
\${ helpers.createBoxLink({label: "All", popup: "Show all bookmarks", dataType: "all" }) } \${ helpers.createBoxLink({label: "Public", popup: "Show public bookmarks", dataType: "public" }) } \${ helpers.createBoxLink({label: "Private", popup: "Show private bookmarks", dataType: "private" }) }
\${ helpers.createBoxLink({ popup: "Shuffle", icon: "shuffle", dataType: "order-shuffle" }) } \${ helpers.createBoxLink({label: "All bookmarks", popup: "Bookmark tags", icon: "ppixiv:tag", classes: ["bookmark-tag-button"] }) }
\`}); this.dataSource = dataSource; /\x2f Refresh the displayed label in case we didn't have it when we created the widget. this.dataSource.addEventListener("updated", () => this.tagDropdown.setButtonPopupHighlight(), this._signal); /\x2f The public/private button only makes sense when viewing your own bookmarks. let publicPrivateButtonContainer = this.querySelector(".bookmark-type"); publicPrivateButtonContainer.hidden = !this.dataSource.viewingOwnBookmarks(); /\x2f Set up the public and private buttons. The "all" button also removes shuffle, since it's not /\x2f supported there. this.dataSource.setItem(publicPrivateButtonContainer, { type: "all", fields: {"#show-all": 1, "#shuffle": null}, defaults: {"#show-all": 1} }); this.dataSource.setItem(this.root, { type: "public", fields: {rest: null, "#show-all": 0}, defaults: {"#show-all": 1} }); this.dataSource.setItem(this.root, { type: "private", fields: {rest: "hide", "#show-all": 0}, defaults: {"#show-all": 1} }); /\x2f Shuffle isn't supported for merged bookmarks. If we're on #show-all, make the shuffle button /\x2f also switch to public bookmarks. This is easier than graying it out and trying to explain it /\x2f in the popup, and better than hiding it which makes it hard to find. let args = new helpers.args(this.dataSource.url); let showAll = args.hash.get("show-all") != "0"; let setPublic = showAll? { rest: null, "#show-all": 0 }:{}; this.dataSource.setItem(this.root, {type: "order-shuffle", fields: {"#shuffle": 1, ...setPublic}, toggle: true, defaults: {"#shuffle": null, "#show-all": 1}}); class BookmarkTagsDropdown extends TagDropdownWidget { refreshTags() { for(let tag of this.root.querySelectorAll(".tag-entry")) tag.remove(); this.addTagLink(null); /\x2f All this.addTagLink(""); /\x2f Uncategorized let allTags = Object.keys(dataSource.bookmarkTagCounts); allTags.sort((lhs, rhs) => lhs.toLowerCase().localeCompare(rhs.toLowerCase())); for(let tag of allTags) { /\x2f Skip uncategorized, which is always placed at the beginning. if(tag == "") continue; if(dataSource.bookmarkTagCounts[tag] == 0) continue; this.addTagLink(tag); } } addTagLink(tag) { let label; if(tag == null) label = "All bookmarks"; else if(tag == "") label = "Untagged"; else label = tag; let a = helpers.createBoxLink({ label, classes: ["tag-entry"], popup: dataSource.bookmarkTagCounts[tag], link: "#", asElement: true, dataType: "bookmark-tag", }); if(label == "All bookmarks") a.dataset.default = 1; if(tag == "") tag = "未分類"; /\x2f Uncategorized dataSource.setItem(a, { urlFormat: "users/id/bookmarks/type/tag", fields: {"/tag": tag}, }); this.root.appendChild(a); }; }; /\x2f Create the bookmark tag dropdown. this.tagDropdown = new DropdownMenuOpener({ button: this.querySelector(".bookmark-tag-button"), createDropdown: ({...options}) => new BookmarkTagsDropdown({dataSource, ...options}), }); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/bookmarks.js `), "/vview/sites/pixiv/data-sources/completed-requests.js": loadBlob("application/javascript", `import DataSource from '/vview/sites/data-source.js'; import Widget from '/vview/widgets/widget.js'; import { helpers } from '/vview/misc/helpers.js'; export default class DataSources_CompletedRequests extends DataSource { get name() { return "completed-requests"; } get pageTitle() { return "Completed requests"; }; getDisplayingText() { return "Completed requests"; } get ui() { return UI; } get supportsStartPage() { return true; } async loadPageInternal(page) { let args = new helpers.args(new URL(this.url)); let showing = args.get("type") || "latest"; /\x2f "latest" or "recommended" let mode = args.get("mode") || "all"; let type = args.getPathnameSegment(2); /\x2f "illust" in "request/complete/illust" let result = await helpers.pixivRequest.get(\`/ajax/commission/page/request/complete/\${type}\`, { mode, p: page, lang: "en", }); /\x2f Convert the request data from an array to a dictionary. let request_data = {}; for(let request of result.body.requests) request_data[request.requestId] = request; for(let user of result.body.users) ppixiv.userCache.addUserData(user); await ppixiv.mediaCache.addMediaInfosPartial(result.body.thumbnails.illust, "normal"); ppixiv.tagTranslations.addTranslationsDict(result.body.tagTranslation); let mediaIds = []; let requestIds = result.body.page[showing == "latest"? "requestIds":"recommendRequestIds"]; if(requestIds == null) return; for(let requestId of requestIds) { /\x2f This has info for the request, like the requester and request text, but we just show these /\x2f as regular posts. let request = request_data[requestId]; let request_post_id = request.postWork.postWorkId; let mediaId = helpers.mediaId.fromIllustId(request_post_id); /\x2f This returns a lot of post IDs that don't exist. Why are people deleting so many of these? /\x2f Check whether the post was in result.body.thumbnails.illust. if(ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }) == null) continue; mediaIds.push(mediaId); } return { mediaIds }; } } class UI extends Widget { constructor({ dataSource, ...options }) { super({ ...options, template: \`
\${ helpers.createBoxLink({label: "Latest", popup: "Show latest completed requests", dataType: "completed-requests-latest" }) } \${ helpers.createBoxLink({label: "Recommended", popup: "Show recommmended completed requests", dataType: "completed-requests-recommended" }) }
\${ helpers.createBoxLink({label: "Illustrations", popup: "Show latest completed requests", dataType: "completed-requests-illust" }) } \${ helpers.createBoxLink({label: "Animations", popup: "Show animations only", dataType: "completed-requests-ugoira" }) } \${ helpers.createBoxLink({label: "Manga", popup: "Show manga only", dataType: "completed-requests-manga" }) }
\${ helpers.createBoxLink({label: "All", popup: "Show all works", dataType: "completed-requests-all" }) } \${ helpers.createBoxLink({label: "All ages", popup: "Show all-ages works", dataType: "completed-requests-safe" }) } \${ helpers.createBoxLink({label: "R18", popup: "Show R18 works", dataType: "completed-requests-r18", classes: ["r18"] }) }
\`}); dataSource.setItem(this.root, { type: "completed-requests-latest", fields: {type: "latest"}, defaults: {type: "latest"}}); dataSource.setItem(this.root, { type: "completed-requests-recommended", fields: {type: "recommended"}, defaults: {type: "latest"}}); dataSource.setItem(this.root, { type: "completed-requests-all", fields: {mode: "all"}, defaults: {mode: "all"}}); dataSource.setItem(this.root, { type: "completed-requests-safe", fields: {mode: "safe"}, defaults: {mode: "all"}}); dataSource.setItem(this.root, { type: "completed-requests-r18", fields: {mode: "r18"}, defaults: {mode: "all"}}); let urlFormat = "request/complete/type"; dataSource.setItem(this.root, { urlFormat: urlFormat, type: "completed-requests-illust", fields: {"/type": "illust"} }); dataSource.setItem(this.root, { urlFormat: urlFormat, type: "completed-requests-ugoira", fields: {"/type": "ugoira"} }); dataSource.setItem(this.root, { urlFormat: urlFormat, type: "completed-requests-manga", fields: {"/type": "manga"} }); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/completed-requests.js `), "/vview/sites/pixiv/data-sources/discover-illusts.js": loadBlob("application/javascript", `/\x2f /discovery - Recommended Works import DataSource from '/vview/sites/data-source.js'; import Widget from '/vview/widgets/widget.js'; import { helpers } from '/vview/misc/helpers.js'; export default class DataSource_Discovery extends DataSource { get name() { return "discovery"; } get pageTitle() { return "Discovery"; } getDisplayingText() { return "Recommended Works"; } get ui() { return UI; } get estimatedItemsPerPage() { return 60; } async loadPageInternal(page) { /\x2f Get "mode" from the URL. If it's not present, use "all". let mode = this.url.searchParams.get("mode") || "all"; let result = await helpers.pixivRequest.get("/ajax/discovery/artworks", { limit: this.estimatedItemsPerPage, mode: mode, lang: "en", }); /\x2f result.body.recommendedIllusts[].recommendMethods, recommendSeedIllustIds /\x2f has info about why it recommended it. let thumbs = result.body.thumbnails.illust; await ppixiv.mediaCache.addMediaInfosPartial(thumbs, "normal"); let mediaIds = []; for(let thumb of thumbs) mediaIds.push(helpers.mediaId.fromIllustId(thumb.id)); ppixiv.tagTranslations.addTranslationsDict(result.body.tagTranslation); return { mediaIds } } } class UI extends Widget { constructor({ dataSource, ...options }) { super({ ...options, template: \`
\${ helpers.createBoxLink({label: "All", popup: "Show all works", dataType: "all" }) } \${ helpers.createBoxLink({label: "All ages", popup: "All ages", dataType: "safe" }) } \${ helpers.createBoxLink({label: "R18", popup: "R18", dataType: "r18", classes: ["r18"] }) }
\`}); dataSource.setItem(this.root, { type: "all", fields: {mode: "all"}, defaults: {mode: "all"} }); dataSource.setItem(this.root, { type: "safe", fields: {mode: "safe"}, defaults: {mode: "all"} }); dataSource.setItem(this.root, { type: "r18", fields: {mode: "r18"}, defaults: {mode: "all"} }); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/discover-illusts.js `), "/vview/sites/pixiv/data-sources/discover-users.js": loadBlob("application/javascript", ` /\x2f Artist suggestions take a random sample of followed users, and query suggestions from them. /\x2f The followed user list normally comes from /discovery/users. /\x2f /\x2f This can also be used to view recommendations based on a specific user. Note that if we're /\x2f doing this, we don't show things like the artist's avatar in the corner, so it doesn't look /\x2f like the images we're showing are by that user. import DataSource from '/vview/sites/data-source.js'; import { helpers } from '/vview/misc/helpers.js'; export default class DataSource_DiscoverUsers extends DataSource { get name() { return "discovery_users"; } constructor(options) { super(options); let args = new helpers.args(this.url); let userId = args.hash.get("user_id"); if(userId != null) this.showingUserId = userId; this.seenUserIds = {}; } get usersPerPage() { return 20; } get estimatedItemsPerPage() { let illustsPerUser = this.showingUserId != null? 3:5; return this.usersPerPage + (usersPerPage * illustsPerUser); } async loadPageInternal(page) { /\x2f If we're showing similar users, only show one page, since the API returns the /\x2f same thing every time. if(this.showingUserId && page > 1) return; if(this.showingUserId != null) { /\x2f Make sure the user info is loaded. this.userInfo = await ppixiv.userCache.getUserInfo(this.showingUserId, { full: true }); /\x2f Update to refresh our page title, which uses user_info. this.callUpdateListeners(); } /\x2f Get suggestions. Each entry is a user, and contains info about a small selection of /\x2f images. let result; if(this.showingUserId != null) { result = await helpers.pixivRequest.get(\`/ajax/user/\${this.showingUserId}/recommends\`, { userNum: this.usersPerPage, workNum: 8, isR18: true, lang: "en" }); } else { result = await helpers.pixivRequest.get("/ajax/discovery/users", { limit: this.usersPerPage, lang: "en", }); /\x2f This one includes tag translations. ppixiv.tagTranslations.addTranslationsDict(result.body.tagTranslation); } if(result.error) throw "Error reading suggestions: " + result.message; await ppixiv.mediaCache.addMediaInfosPartial(result.body.thumbnails.illust, "normal"); for(let user of result.body.users) { ppixiv.userCache.addUserData(user); /\x2f Register this as quick user data, for use in thumbnails. ppixiv.extraCache.addQuickUserData(user, "recommendations"); } /\x2f Pixiv's motto: "never do the same thing the same way twice" /\x2f ajax/user/#/recommends is body.recommendUsers and user.illustIds. /\x2f discovery/users is body.recommendedUsers and user.recentIllustIds. let recommendedUsers = result.body.recommendUsers || result.body.recommendedUsers; let mediaIds = []; for(let user of recommendedUsers) { /\x2f Each time we load a "page", we're actually just getting a new randomized set of recommendations /\x2f for our seed, so we'll often get duplicate results. Ignore users that we've seen already. IllustIdList /\x2f will remove dupes, but we might get different sample illustrations for a duplicated artist, and /\x2f those wouldn't be removed. if(this.seenUserIds[user.userId]) continue; this.seenUserIds[user.userId] = true; mediaIds.push("user:" + user.userId); let illustIds = user.illustIds || user.recentIllustIds; for(let illustId of illustIds) mediaIds.push(helpers.mediaId.fromIllustId(illustId)); } return { mediaIds }; } get estimatedItemsPerPage() { return 30; } get pageTitle() { if(this.showingUserId == null) return "Recommended Users"; if(this.userInfo) return this.userInfo.name; else return "Loading..."; } getDisplayingText() { if(this.showingUserId == null) return "Recommended Users"; if(this.userInfo) return "Similar artists to " + this.userInfo.name; else return "Illustrations"; }; }; /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/discover-users.js `), "/vview/sites/pixiv/data-sources/edited-images.js": loadBlob("application/javascript", ` /\x2f https:/\x2fwww.pixiv.net/en/#ppixiv/edits /\x2f View images that have edits on them /\x2f /\x2f This views all images that the user has saved crops, etc. for. This isn't currently /\x2f shown in the UI. import { DataSourceFakePagination } from '/vview/sites/data-source.js'; export default class DataSources_EditedImages extends DataSourceFakePagination { get name() { return "edited"; } get pageTitle() { return "Edited"; } getDisplayingText() { return "Edited Images"; } /\x2f This can return manga pages directly, so don't allow expanding pages. get allowExpandingMangaPages() { return false; } async loadAllResults() { return await ppixiv.extraImageData.getAllEditedImages(); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/edited-images.js `), "/vview/sites/pixiv/data-sources/followed-users.js": loadBlob("application/javascript", `import DataSource from '/vview/sites/data-source.js'; import Widget from '/vview/widgets/widget.js'; import { DropdownMenuOpener } from '/vview/widgets/dropdown.js'; import { helpers } from '/vview/misc/helpers.js'; export default class DataSource_Follows extends DataSource { get name() { return "following"; } get supportsStartPage() { return true;} get ui() { return UI; } constructor(args) { super(args); this.followTags = []; } get viewingUserId() { if(helpers.strings.getPathPart(this.url, 0) == "users") { /\x2f New URLs (/users/13245/follows) return helpers.strings.getPathPart(this.url, 1); } let queryArgs = this.url.searchParams; let userId = queryArgs.get("id"); if(userId == null) return ppixiv.pixivInfo.userId; return userId; }; async loadPageInternal(page) { /\x2f Make sure the user info is loaded. This should normally be preloaded by globalInitData /\x2f in main.js, and this won't make a request. this.userInfo = await ppixiv.userCache.getUserInfo(this.viewingUserId, { full: true }); /\x2f Update to refresh our page title, which uses userInfo. this.callUpdateListeners(); let queryArgs = this.url.searchParams; let rest = queryArgs.get("rest") || "show"; let acceptingRequests = queryArgs.get("acceptingRequests") || "0"; let url = "/ajax/user/" + this.viewingUserId + "/following"; let args = { offset: this.estimatedItemsPerPage*(page-1), limit: this.estimatedItemsPerPage, rest: rest, acceptingRequests, }; if(queryArgs.get("tag")) args.tag = queryArgs.get("tag"); let result = await helpers.pixivRequest.get(url, args); /\x2f Store following tags. this.followTags = result.body.followUserTags; this.followTags.sort((lhs, rhs) => lhs.toLowerCase().localeCompare(rhs.toLowerCase())); this.callUpdateListeners(); /\x2f Make a list of the first illustration for each user. let illusts = []; for(let followedUser of result.body.users) { if(followedUser == null) continue; /\x2f Register this as quick user data, for use in thumbnails. ppixiv.extraCache.addQuickUserData(followedUser, "following"); if(!followedUser.illusts.length) { console.log("Can't show followed user that has no posts:", followedUser.userId); continue; } let illust = followedUser.illusts[0]; illusts.push(illust); /\x2f We'll register this with MediaCache below. These results don't have profileImageUrl /\x2f and only put it in the enclosing user, so copy it over. illust.profileImageUrl = followedUser.profileImageUrl; } let mediaIds = []; for(let illust of illusts) mediaIds.push("user:" + illust.userId); await ppixiv.mediaCache.addMediaInfosPartial(illusts, "normal"); return { mediaIds }; } get uiInfo() { return { userId: this.viewingSelf? null:this.viewingUserId, } } get viewingSelf() { return this.viewingUserId == ppixiv.pixivInfo.userId; } get pageTitle() { if(!this.viewingSelf) { if(this.userInfo) return this.userInfo.name + "'s Follows"; return "User's follows"; } let queryArgs = this.url.searchParams; let privateFollows = queryArgs.get("rest") == "hide"; return privateFollows? "Private follows":"Followed users"; }; getDisplayingText() { if(!this.viewingSelf) { if(this.userInfo) return this.userInfo.name + "'s followed users"; return "User's followed users"; } let queryArgs = this.url.searchParams; let privateFollows = queryArgs.get("rest") == "hide"; return privateFollows? "Private follows":"Followed users"; }; } class UI extends Widget { constructor({ dataSource, ...options }) { super({ ...options, template: \`
\${ helpers.createBoxLink({label: "Public", popup: "Show publically followed users", dataType: "public-follows" }) } \${ helpers.createBoxLink({label: "Private", popup: "Show privately followed users", dataType: "private-follows" }) } \${ helpers.createBoxLink({ popup: "Accepting requests", icon: "paid", dataType: "accepting-requests" }) }
\${ helpers.createBoxLink({label: "All tags", popup: "Follow tags", icon: "bookmark", classes: ["follow-tags-button", "premium-only"] }) }
\`}); this.dataSource = dataSource; /\x2f The public/private button only makes sense when viewing your own follows. let publicPrivateButtonContainer = this.querySelector(".follows-public-private"); publicPrivateButtonContainer.hidden = !dataSource.viewingSelf; dataSource.setItem(this.root, { type: "public-follows", fields: {rest: "show"}, defaults: {rest: "show"} }); dataSource.setItem(this.root, { type: "private-follows", fields: {rest: "hide"}, defaults: {rest: "show"} }); dataSource.setItem(this.root, { type: "accepting-requests", toggle: true, fields: {acceptingRequests: "1"}, defaults: {acceptingRequests: "0"}}); class FollowTabDropdown extends Widget { constructor() { super({ ...options, template: \`\`, }); dataSource.addEventListener("updated", () => this.refreshFollowingTags(), this._signal); this.refreshFollowingTags(); } refreshFollowingTags() { let tagList = this.root; for(let tag of tagList.querySelectorAll(".tag-entry")) tag.remove(); /\x2f Refresh the bookmark tag list. Remove the page number from these buttons. let currentTag = dataSource.url.searchParams.get("tag") || "All tags"; let addTagLink = (tag) => { /\x2f Work around Pixiv always returning a follow tag named "null" for some users. if(tag == "null") return; let a = helpers.createBoxLink({ label: tag, classes: ["tag-entry"], link: "#", asElement: true, dataType: "following-tag", }); if(tag == "All tags") { tag = null; a.dataset.default = 1; } dataSource.setItem(a, { fields: {"tag": tag} }); tagList.appendChild(a); }; addTagLink("All tags"); for(let tag of dataSource.followTags) addTagLink(tag); /\x2f If we don't have the tag list yet because we're still loading the page, fill in /\x2f the current tag, to reduce flicker as the page loads. if(dataSource.followTags.length == 0 && currentTag != "All tags") addTagLink(currentTag); } } /\x2f Create the follow tag dropdown. new DropdownMenuOpener({ button: this.querySelector(".follow-tags-button"), createDropdown: ({...options}) => new FollowTabDropdown({dataSource, ...options}), }); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/followed-users.js `), "/vview/sites/pixiv/data-sources/illust.js": loadBlob("application/javascript", `import DataSource from '/vview/sites/data-source.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f /artworks/# - Viewing a single illustration /\x2f /\x2f This is a stub for when we're viewing an image with no search. it /\x2f doesn't return any search results. export default class DataSource_Illust extends DataSource { get name() { return "illust"; } constructor(args) { super(args); this.mediaId = this.getUrlMediaId(new helpers.args(this.url)); this._loadMediaInfo(); } async _loadMediaInfo() { this.mediaInfo = await ppixiv.mediaCache.getMediaInfo(this.mediaId, { full: false }); } /\x2f Show the illustration by default. get defaultScreen() { return "illust";} /\x2f This data source just views a single image and doesn't return any posts. async loadPageInternal(page) { } getUrlMediaId(args) { /\x2f The illust ID is stored in the path, for compatibility with Pixiv URLs: /\x2f /\x2f https:/\x2fwww.pixiv.net/en/users/#/artworks /\x2f /\x2f The page (if any) is stored in the hash. let url = args.url; url = helpers.pixiv.getUrlWithoutLanguage(url); let parts = url.pathname.split("/"); let illustId = parts[2]; let page = this.getUrlMangaPage(args); return helpers.mediaId.fromIllustId(illustId, page); } /\x2f Use the artist's page as the view if we're trying to return to a search for this data /\x2f source. get searchUrl() { if(this.mediaInfo) return new URL(\`/users/\${this.mediaInfo.userId}/artworks#ppixiv\`, this.url); else return this.url; } /\x2f We don't return any posts to navigate to, but this can still be called by /\x2f quick view. setUrlMediaId(mediaId, args) { let [illustId, page] = helpers.mediaId.toIllustIdAndPage(mediaId); /\x2f Pixiv's inconsistent URLs are annoying. Figure out where the ID field is. /\x2f If the first field is a language, it's the third field (/en/artworks/#), otherwise /\x2f it's the second (/artworks/#). let parts = args.path.split("/"); let id_part = parts[1].length == 2? 3:2; parts[id_part] = illustId; args.path = parts.join("/"); args.hash.set("page", page+1); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/illust.js `), "/vview/sites/pixiv/data-sources/manga-pages.js": loadBlob("application/javascript", `import DataSource from '/vview/sites/data-source.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f /artworks/illust_id?manga - Viewing manga pages for an illustration export default class DataSource_MangaPages extends DataSource { get name() { return "manga"; } get allowExpandingMangaPages() { return false; } constructor(args) { super(args); /\x2f /artworks/# let url = new URL(this.url); url = helpers.pixiv.getUrlWithoutLanguage(url); let parts = url.pathname.split("/"); let illustId = parts[2]; this.mediaId = helpers.mediaId.fromIllustId(illustId); } async loadPageInternal(page) { if(page != 1) return; /\x2f Get media info for the page count. this.mediaInfo = await ppixiv.mediaCache.getMediaInfo(this.mediaId, { full: false }); if(this.mediaInfo == null) return; /\x2f Refresh the title. this.callUpdateListeners(); let mediaIds = []; for(let page = 0; page < this.mediaInfo.pageCount; ++page) mediaIds.push(helpers.mediaId.getMediaIdForPage(this.mediaId, page)); /\x2f Preload thumbs before continuing. This allows extraCache to know the aspect ratio of /\x2f the image, so it's available to SearchView for aspect ratio thumbs. These will often /\x2f already be cached from the view we came here from. let { promise } = ppixiv.extraCache.batchGetMediaAspectRatio(mediaIds); await promise; return { mediaIds }; } get pageTitle() { if(this.mediaInfo) return this.mediaInfo.userName + " - " + this.mediaInfo.illustTitle; else return "Illustrations"; } getDisplayingText() { if(this.mediaInfo) return this.mediaInfo.illustTitle + " by " + this.mediaInfo.userName; else return "Illustrations"; }; get uiInfo() { return { userId: this.mediaInfo?.userId, } } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/manga-pages.js `), "/vview/sites/pixiv/data-sources/new-posts-by-everyone.js": loadBlob("application/javascript", `import DataSource from '/vview/sites/data-source.js'; import Widget from '/vview/widgets/widget.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f new_illust.php export default class DataSource_NewPostsByEveryone extends DataSource { get name() { return "new_illust"; } get pageTitle() { return "New Works"; } getDisplayingText() { return "New Works"; } get ui() { return UI; } async loadPageInternal(page) { let args = new helpers.args(this.url); /\x2f new_illust.php or new_illust_r18.php: let r18 = this.url.pathname == "/new_illust_r18.php"; let type = args.query.get("type") || "illust"; /\x2f Everything Pixiv does has always been based on page numbers, but this one uses starting IDs. /\x2f That's a better way (avoids duplicates when moving forward in the list), but it's inconsistent /\x2f with everything else. We usually load from page 1 upwards. If we're loading the next page and /\x2f we have a previous last_id, assume it starts at that ID. /\x2f /\x2f This makes some assumptions about how we're called: that we won't be called for the same page /\x2f multiple times and we're always loaded in ascending order. In practice this is almost always /\x2f true. If Pixiv starts using this method for more important pages it might be worth checking /\x2f this more carefully. if(this.lastId == null) { this.lastId = 0; this.lastIdPage = 1; } if(this.lastIdPage != page) { console.error("Pages weren't loaded in order"); return; } console.log("Assuming page", page, "starts at", this.lastId); let url = "/ajax/illust/new"; let result = await helpers.pixivRequest.get(url, { limit: 20, type: type, r18: r18, lastId: this.lastId, }); if(result.body.illusts.length > 0) { this.lastId = result.body.illusts[result.body.illusts.length-1].id; this.lastIdPage++; } let mediaIds = []; for(let illustData of result.body.illusts) mediaIds.push(helpers.mediaId.fromIllustId(illustData.id)); await ppixiv.mediaCache.addMediaInfosPartial(result.body.illusts, "normal"); return { mediaIds }; } } class UI extends Widget { constructor({ dataSource, ...options }) { super({ ...options, template: \`
\${ helpers.createBoxLink({label: "Illustrations", popup: "Show illustrations", dataType: "new-illust-type-illust" }) } \${ helpers.createBoxLink({label: "Manga", popup: "Show manga only", dataType: "new-illust-type-manga" }) }
\${ helpers.createBoxLink({label: "R18", popup: "Show only R18 works", dataType: "new-illust-ages-r18" }) }
\`}); dataSource.setItem(this.root, { type: "new-illust-type-illust", fields: {type: null} }); dataSource.setItem(this.root, { type: "new-illust-type-manga", fields: {type: "manga"} }); dataSource.setItem(this.root, { type: "new-illust-ages-r18", toggle: true, urlFormat: "path", fields: {"/path": "new_illust_r18.php"}, defaults: {"/path": "new_illust.php"}, }); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/new-posts-by-everyone.js `), "/vview/sites/pixiv/data-sources/new-posts-by-following.js": loadBlob("application/javascript", `import DataSource, { TagDropdownWidget } from '/vview/sites/data-source.js'; import Widget from '/vview/widgets/widget.js'; import { DropdownMenuOpener } from '/vview/widgets/dropdown.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f bookmark_new_illust.php, bookmark_new_illust_r18.php export default class DataSource_NewPostsByFollowing extends DataSource { get name() { return "new_works_by_following"; } get pageTitle() { return "Following"; } getDisplayingText() { return "Following"; } get ui() { return UI; } constructor(args) { super(args); this.bookmarkTags = []; } get supportsStartPage() { return true; } async loadPageInternal(page) { let currentTag = this.url.searchParams.get("tag") || ""; let r18 = this.url.pathname == "/bookmark_new_illust_r18.php"; let result = await helpers.pixivRequest.get("/ajax/follow_latest/illust", { p: page, tag: currentTag, mode: r18? "r18":"all", }); let data = result.body; /\x2f Add translations. ppixiv.tagTranslations.addTranslationsDict(data.tagTranslation); /\x2f Store bookmark tags. this.bookmarkTags = data.page.tags; this.bookmarkTags.sort((lhs, rhs) => lhs.toLowerCase().localeCompare(rhs.toLowerCase())); this.callUpdateListeners(); /\x2f Populate thumbnail data with this data. await ppixiv.mediaCache.addMediaInfosPartial(data.thumbnails.illust, "normal"); let mediaIds = []; for(let illust of data.thumbnails.illust) mediaIds.push(helpers.mediaId.fromIllustId(illust.id)); return { mediaIds }; } }; class UI extends Widget { constructor({ dataSource, ...options }) { super({ ...options, template: \`
\${ helpers.createBoxLink({label: "R18", popup: "Show only R18 works", dataType: "bookmarks-new-illust-ages-r18", classes: ["r18"] }) } \${ helpers.createBoxLink({label: "All tags", popup: "Follow tags", icon: "bookmark", classes: ["follow-tag-button", "premium-only"] }) }
\`}); this.dataSource = dataSource; class FollowTagDropdown extends TagDropdownWidget { refreshTags() { /\x2f Refresh the bookmark tag list. let currentTag = dataSource.url.searchParams.get("tag") || "All tags"; for(let tag of this.root.querySelectorAll(".tag-entry")) tag.remove(); this.addTagLink("All tags"); for(let tag of dataSource.bookmarkTags) this.addTagLink(tag); /\x2f If we don't have the tag list yet because we're still loading the page, fill in /\x2f the current tag, to reduce flicker as the page loads. if(dataSource.bookmarkTags.length == 0 && currentTag != "All tags") this.addTagLink(currentTag); } addTagLink(tag) { /\x2f Work around Pixiv always returning a follow tag named "null" for some users. if(tag == "null") return; let label = tag; if(tag == "All tags") tag = null; let a = helpers.createBoxLink({ label, classes: ["tag-entry"], link: "#", asElement: true, dataType: "following-tag", }); if(label == "All tags") a.dataset.default = 1; dataSource.setItem(a, { fields: {"tag": tag} }); this.root.appendChild(a); }; }; /\x2f Create the follow tag dropdown. new DropdownMenuOpener({ button: this.querySelector(".follow-tag-button"), createDropdown: ({...options}) => new FollowTagDropdown({dataSource, ...options}), }); dataSource.setItem(this.root, { type: "bookmarks-new-illust-ages-r18", toggle: true, urlFormat: "path", fields: {"/path": "bookmark_new_illust_r18.php"}, defaults: {"/path": "bookmark_new_illust.php"}, }); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/new-posts-by-following.js `), "/vview/sites/pixiv/data-sources/rankings.js": loadBlob("application/javascript", `/\x2f /ranking.php /\x2f /\x2f This one has an API, and also formats the first page of results into the page. /\x2f They have completely different formats, and the page is updated dynamically (unlike /\x2f the pages we scrape), so we ignore the page for this one and just use the API. /\x2f /\x2f An exception is that we load the previous and next days from the page. This is better /\x2f than using our current date, since it makes sure we have the same view of time as /\x2f the search results. import DataSource from '/vview/sites/data-source.js'; import Widget from '/vview/widgets/widget.js'; import { DropdownMenuOpener } from '/vview/widgets/dropdown.js'; import { helpers } from '/vview/misc/helpers.js'; export default class DataSource_Rankings extends DataSource { get name() { return "rankings"; } get pageTitle() { return "Rankings"; } getDisplayingText() { return "Rankings"; } get ui() { return UI; } /\x2f This gives a tiny number of results per page on mobile. get estimatedItemsPerPage() { return ppixiv.mobile? 18:50; } constructor(args) { super(args); this.maxPage = 999999; } /\x2f A Pixiv classic: two separate, vaguely-similar ways of doing the same thing on desktop /\x2f and mobile (and a third, mobile apps). It's like they use the same backend but are /\x2f implemented by two people who never talk to each other. The desktop version is /\x2f preferred since it gives us thumbnail data, where the mobile version only gives /\x2f thumbnail IDs that we have to look up, but the desktop version can't be accessed /\x2f from mobile. async loadDataMobile({ date, mode, content, page }) { let data = { mode, page, type: content, }; if(date) data.date = date; let result = await helpers.pixivRequest.get("/touch/ajax/ranking/illust", data); let thisDate = result.body.rankingDate; function formatDate(date) { let year = date.getUTCFullYear(); let month = date.getUTCMonth() + 1; let day = date.getUTCDate(); return year + "-" + month.toString().padStart(2, '0') + "-" + day.toString().padStart(2, '0'); } /\x2f This API doesn't tell us the previous and next ranking dates, so we have to figure /\x2f it out ourself. let nextDate = new Date(thisDate); let prevDate = new Date(thisDate); nextDate.setDate(nextDate.getDate() + 1); prevDate.setDate(prevDate.getDate() - 1); nextDate = formatDate(nextDate); prevDate = formatDate(prevDate); /\x2f This version doesn't indicate the last page, and just keeps loading until it gets /\x2f an empty response. It also doesn't indicate the first page where a ranking type /\x2f starts. For example, AI results begin on 2022-10-31. I'm not sure how to guess /\x2f the last page. Are these dates UTC or JST? Are new results available at exactly /\x2f midnight? let lastPage = false; let mediaIds = []; for(let item of result.body.ranking) mediaIds.push(helpers.mediaId.fromIllustId("" + item.illustId)); /\x2f All search APIs return image info except for this one, so we need to load it to /\x2f behave the same as other data source. await ppixiv.mediaCache.batchGetMediaInfoPartial(mediaIds); /\x2f Muted images won't be returned. Make sure we don't return media IDs that we /\x2f haven't registered media info for. let foundMediaIds = []; for(let mediaId of mediaIds) { if(ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false })) foundMediaIds.push(mediaId); } return { mediaIds: foundMediaIds, thisDate, nextDate, prevDate, lastPage }; } async loadDataDesktop({ date, mode, content, page }) { let data = { content, mode, format: "json", p: page, }; if(date) data.date = date; let result = await helpers.pixivRequest.get("/ranking.php", data); let thisDate = result.date; let nextDate = result.next_date; let prevDate = result.prev_date; let lastPage = !result.next; /\x2f Fix nextDate and prevDate being false instead of null if there's no previous /\x2f or next date. if(!nextDate) nextDate = null; if(!prevDate) prevDate = null; /\x2f This is "YYYYMMDD". Reformat it to YYYY-MM-DD. if(thisDate.length == 8) { let year = thisDate.slice(0,4); let month = thisDate.slice(4,6); let day = thisDate.slice(6,8); thisDate = year + "/" + month + "/" + day; } /\x2f This API doesn't return aiType, but we can fill it in ourself since we know whether /\x2f we're on an AI rankings page or not. let isAI = mode == "daily_ai" || mode == "daily_r18_ai"; for(let illust of result.contents) illust.aiType = isAI? 2:1; /\x2f This returns a struct of data that's like the thumbnails data response, /\x2f but it's not quite the same. let mediaIds = []; for(let item of result.contents) mediaIds.push(helpers.mediaId.fromIllustId("" + item.illust_id)); /\x2f Register this as thumbnail data. await ppixiv.mediaCache.addMediaInfosPartial(result.contents, "rankings"); return { mediaIds, thisDate, nextDate, prevDate, lastPage }; } loadDataForPlatform(options) { if(ppixiv.mobile) return this.loadDataMobile(options); else return this.loadDataDesktop(options); } async loadPageInternal(page) { /\x2f Stop if we already know this is past the end. if(page > this.maxPage) return; let queryArgs = this.url.searchParams; let date = queryArgs.get("date"); let mode = queryArgs.get("mode") ?? "daily"; let content = queryArgs.get("content") ?? "all"; let { mediaIds, thisDate, nextDate, prevDate, lastPage } = await this.loadDataForPlatform({ date, mode, content, page }); if(lastPage) this.maxPage = Math.min(page, this.maxPage); this.todayText ??= thisDate; this.prevDate = prevDate; this.nextDate = nextDate; this.callUpdateListeners(); return { mediaIds }; }; } class UI extends Widget { constructor({dataSource, ...options}) { super({ ...options, template: \`
\${ helpers.createBoxLink({label: "Previous day", popup: "Show the previous day", dataType: "new-illust-type-illust", classes: ["nav-yesterday"] }) } \${ helpers.createBoxLink({label: "Next day", popup: "Show the next day", dataType: "new-illust-type-illust", classes: ["nav-tomorrow"] }) }
\${ helpers.createBoxLink({label: "Ranking type", popup: "Rankings to display", classes: ["mode-button"] }) } \${ helpers.createBoxLink({label: "Contents", popup: "Content type to display", classes: ["content-type-button"] }) }
\`}); this.dataSource = dataSource; dataSource.addEventListener("updated", () => this.refreshDates(), this._signal); this.refreshDates(); /* * Pixiv has a fixed list of rankings, but it displays them as a set of buttons * based on the current selection, showing which rankings are available in the current * category. * * These are the available ranking modes, and whether they're available in overall, * content=illust/manga (these have the same selections) and content=ugoira, and * whether there are R18 rankings. R18 rankings have the same mode name with "_r18" * appended. (Except for AI which puts it in the middle, because Pixiv.) * * Be careful: Pixiv's UI has buttons in some filters that don't actually exist in the * mode it's in, which actually redirect out of the content mode, such as "popular among * male users" in "Illustrations" mode which actually goes back to "Overall". * * o: overall (all) o*: overall R18 o**: overall R18G * i: illust/manga i*: illust/manga R18 i**: illust/manga R18G * u: ugoira u*: ugoira R18 */ let rankingTypes = { /\x2f Overall Illust Ugoira /\x2f R18 R18 R18 "daily": { content: ["o", "o*", "i", "i*", "u", "u*"], label: "Daily", popup: "Daily rankings" }, /\x2f Weekly also has "r18g" for most content types. "weekly": { content: ["o", "o*", "i", "i*", "u", "u*", "o**", "i**"], label: "Weekly", popup: "Weekly rankings" }, "monthly": { content: ["o", "i"], label: "Monthly", popup: "Monthly rankings" }, "rookie": { content: ["o", "i"], label: "Rookie", popup: "Rookie rankings" }, "original": { content: ["o"], label: "Original", popup: "Original rankings" }, "daily_ai": { content: ["o", "o*"], label: "AI", popup: "Show AI works" }, "male": { content: ["o", "o*"], label: "Male", popup: "Popular with men" }, "female": { content: ["o", "o*"], label: "Female", popup: "Popular with women" }, }; /\x2f Given a content selection ("all", "illust", "manga", "ugoira") and an ages selection, return /\x2f the shorthand key for this combination, such as "i*". function contentKeyFor(content, ages) { let keys = { "all": "o", "illust": "i", "manga": "i" /* same as illust */, "ugoira": "u" }; let contentKey = keys[content]; /\x2f Append * for r18 and ** for r18g. if(ages == "r18") contentKey += "*"; else if(ages == "r18g") contentKey += "**"; return contentKey; } /\x2f Given a mode ("daily") and an ages selection ("r18"), return the combined mode, /\x2f eg. "daily_r18". function modeWithAges(mode, ages) { if(ages == "r18") mode += "_r18"; /\x2f daily_r18 else if(ages == "r18g") mode += "_r18g"; /\x2f daily_r18g /\x2f Seriously, guys? if(mode == "daily_ai_r18") mode = "daily_r18_ai"; else if(mode == "weekly_r18g") mode = "r18g"; return mode; } let currentArgs = new helpers.args(dataSource.url); /\x2f The current content type: all, illust, manga, ugoira let currentContent = currentArgs.query.get("content") || "all"; /\x2f The current mode: daily, weekly, etc. let currentMode = currentArgs.query.get("mode") || "daily"; if(currentMode == "r18g") /\x2f work around Pixiv inconsistency currentMode = "weekly_r18g"; /\x2f "all", "r18", "r18g" let currentAges = currentMode.indexOf("r18g") != -1? "r18g": currentMode.indexOf("r18") != -1? "r18":"all"; /\x2f Strip _r18 or _r18g out of currentMode, so currentMode is the base mode, ignoring the /\x2f ages selection. currentMode = currentMode.replace("_r18g", "").replace("_r18", ""); /\x2f The key for the current mode: let contentKey = contentKeyFor(currentContent, currentAges); console.log(\`Rankings content mode: \${currentContent}, ages: \${currentAges}, key: \${contentKey}\`); let modeContainer = this.querySelector(".modes"); /\x2f Create the R18 and R18G buttons. If we're on a selection where toggling this doesn't exist, /\x2f pick a default. for(let agesToggle of ["r18", "r18g"]) { let targetMode = currentMode; let currentRankingType = rankingTypes[currentMode]; console.assert(currentRankingType, currentMode); let { content } = currentRankingType; let button = helpers.createBoxLink({ label: agesToggle.toUpperCase(), popup: \`Show \${agesToggle.toUpperCase()} works\`, classes: [agesToggle], asElement: true, }); modeContainer.appendChild(button); /\x2f If toggling this would put us in a mode that doesn't exist, default to "daily" for R18 and /\x2f "weekly" for R18G, since those combinations always exist. The buttons aren't disabled or /\x2f removed since it makes it confusing to find them. let contentKeyForMode = contentKeyFor(currentContent, agesToggle); if(content.indexOf(contentKeyForMode) == -1) targetMode = agesToggle == "r18"? "daily":"weekly"; let modeEnabled = modeWithAges(targetMode, agesToggle); let modeDisabled = modeWithAges(targetMode, "all"); dataSource.setItem(button, { fields: {mode: modeEnabled}, toggle: true, classes: [agesToggle], /\x2f only show if enabled adjustUrl: (args) => { /\x2f If we're in R18, clicking this would remove the mode field entirely. Instead, /\x2f switch to the all-ages link. if(currentAges == agesToggle) args.query.set("mode", modeDisabled); } }); } /\x2f Create the content dropdown. new DropdownMenuOpener({ button: this.querySelector(".content-type-button"), createDropdown: ({...options}) => { let dropdown = new Widget({ ...options, template: \`
\${ helpers.createBoxLink({label: "All", popup: "Show all works", dataType: "content-all" }) } \${ helpers.createBoxLink({label: "Illustrations", popup: "Show illustrations only", dataType: "content-illust" }) } \${ helpers.createBoxLink({label: "Animations", popup: "Show animations only", dataType: "content-ugoira" }) } \${ helpers.createBoxLink({label: "Manga", popup: "Show manga only", dataType: "content-manga" }) }
\`, }); /\x2f Set up the content links. /\x2f grr: this doesn't work with the dropdown text for(let content of ["all", "illust", "ugoira", "manga"]) { dataSource.setItem(dropdown, { type: "content-" + content, /\x2f content-all, content-illust, etc fields: {content}, defaults: {content: "all"}, adjustUrl: (args) => { if(content == currentContent) return; /\x2f If the current mode and ages combination doesn't exist in the content type /\x2f this link will switch to, also reset the mode to daily, since it exists for /\x2f all "all-ages" modes. let currentRankingType = rankingTypes[currentMode]; console.assert(currentRankingType, currentMode); let switching_to_content_key = contentKeyFor(content, currentAges); if(currentRankingType.content.indexOf(switching_to_content_key) == -1) args.query.set("mode", "daily"); }, }); } return dropdown; }, }); /\x2f Create the mode dropdown. new DropdownMenuOpener({ button: this.querySelector(".mode-button"), createDropdown: ({...options}) => { let dropdown = new Widget({ ...options, template: \`
\` }); /\x2f Create mode links for rankings that exist in the current content and ages selection. for(let [mode, {content, label, popup}] of Object.entries(rankingTypes)) { console.assert(content, mode); mode = modeWithAges(mode, currentAges); /\x2f Skip this mode if it's not available in the selected content and ages combination. if(content.indexOf(contentKey) == -1) continue; let button = helpers.createBoxLink({ label, popup, asElement: true, }); dropdown.root.appendChild(button); dataSource.setItem(button, { fields: {mode}, defaults: {mode: "daily"}, }); } return dropdown; } }); } refreshDates = () => { if(this.dataSource.todayText) this.querySelector(".nav-today").innerText = this.dataSource.todayText; /\x2f This UI is greyed rather than hidden before we have the dates, so the UI doesn't /\x2f shift around as we load. let yesterday = this.querySelector(".nav-yesterday"); helpers.html.setClass(yesterday, "disabled", this.dataSource.prevDate == null); if(this.dataSource.prevDate) { let url = new URL(this.dataSource.url); url.searchParams.set("date", this.dataSource.prevDate); yesterday.href = url; } let tomorrow = this.querySelector(".nav-tomorrow"); helpers.html.setClass(tomorrow, "disabled", this.dataSource.nextDate == null); if(this.dataSource.nextDate) { let url = new URL(this.dataSource.url); url.searchParams.set("date", this.dataSource.nextDate); tomorrow.href = url; } } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/rankings.js `), "/vview/sites/pixiv/data-sources/related-favorites.js": loadBlob("application/javascript", `import { DataSourceFromPage } from '/vview/sites/data-source.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f bookmark_detail.php /\x2f /\x2f This lists the users who publically bookmarked an illustration, linking to each user's bookmarks. export default class DataSource_RelatedFavorites extends DataSourceFromPage { get name() { return "illust-bookmarks"; } get pageTitle() { return "Similar Bookmarks"; } getDisplayingText() { if(this.illustInfo) return "Users who bookmarked " + this.illustInfo.illustTitle; else return "Users who bookmarked image"; }; constructor(args) { super(args); this.illustInfo = null; } async loadPageInternal(page) { /\x2f Get info for the illustration we're displaying bookmarks for. let queryArgs = this.url.searchParams; let illustId = queryArgs.get("illustId"); let mediaId = helpers.mediaId.fromIllustId(illustId) this.illustInfo = await ppixiv.mediaCache.getMediaInfo(mediaId); this.callUpdateListeners(); return super.loadPageInternal(page); } /\x2f Parse the loaded document and return the illust_ids. parseDocument(doc) { let ids = []; for(let element of doc.querySelectorAll("li.bookmark-item a[data-user_id]")) { /\x2f Register this as quick user data, for use in thumbnails. ppixiv.extraCache.addQuickUserData({ user_id: element.dataset.user_id, user_name: element.dataset.user_name, /\x2f This page gives links to very low-res avatars. Replace them with the high-res ones /\x2f that newer pages give. /\x2f /\x2f These links might be annoying animated GIFs, but we don't bother killing them here /\x2f 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"); /\x2f The bookmarks: URL type will generate links to this user's bookmarks. ids.push("bookmarks:" + element.dataset.user_id); } return ids; } get uiInfo() { let imageUrl = null; let imageLinkUrl = null; if(this.illustInfo) { imageLinkUrl = \`/artworks/\${this.illustInfo.id}#ppixiv\`; imageUrl = this.illustInfo.previewUrls[0]; } return { imageUrl, imageLinkUrl }; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/related-favorites.js `), "/vview/sites/pixiv/data-sources/search-illusts.js": loadBlob("application/javascript", `/\x2f /tags /\x2f /\x2f The new tag search UI is a bewildering mess: /\x2f /\x2f - Searching for a tag goes to "/tags/TAG/artworks". This searches all posts with the /\x2f tag. The API query is "/ajax/search/artworks/TAG". The "top" tab is highlighted, but /\x2f it's not actually on that tab and no tab button goes back here. "Illustrations, Manga, /\x2f Ugoira" in search options also goes here. /\x2f /\x2f - The "Illustrations" tab goes to "/tags/TAG/illustrations". The API is /\x2f "/ajax/search/illustrations/TAG?type=illust_and_ugoira". This is almost identical to /\x2f "artworks", but excludes posts marked as manga. "Illustrations, Ugoira" in search /\x2f options also goes here. /\x2f /\x2f - Clicking "manga" goes to "/tags/TAG/manga". The API is "/ajax/search/manga" and also /\x2f sets type=manga. This is "Manga" in the search options. This page is also useless. /\x2f /\x2f The "manga only" and "exclude manga" pages are useless, since Pixiv doesn't make any /\x2f useful distinction between "manga" and "illustrations with more than one page". We /\x2f only include them for completeness. /\x2f /\x2f - You can search for just animations, but there's no button for it in the UI. You /\x2f have to pick it from the dropdown in search options. This one is "illustrations?type=ugoira". /\x2f Why did they keep using type just for one search mode? Saying "type=manga" or any /\x2f other type fails, so it really is just used for this. /\x2f /\x2f - Clicking "Top" goes to "/tags/TAG" with no type. This is a completely different /\x2f page and API, "/ajax/search/top/TAG". It doesn't actually seem to be a rankings /\x2f page and just shows the same thing as the others with a different layout, so we /\x2f ignore this and treat it like "artworks". import DataSource, { TagDropdownWidget } from '/vview/sites/data-source.js'; import Widget from '/vview/widgets/widget.js'; import SavedSearchTags from '/vview/misc/saved-search-tags.js'; import { TagSearchBoxWidget } from '/vview/widgets/tag-search-dropdown.js'; import { DropdownMenuOpener } from '/vview/widgets/dropdown.js'; import { helpers } from '/vview/misc/helpers.js'; export default class DataSource_Search extends DataSource { get name() { return "search"; } get ui() { return UI; } constructor(args) { super(args); /\x2f Add the search tags to tag history. We only do this at the start when the /\x2f data source is created, not every time we navigate back to the search. let tag = this._searchTags; if(tag) SavedSearchTags.add(tag); this.cacheSearchTitle(); } get supportsStartPage() { return true; } get hasNoResults() { /\x2f Don't display "No Results" while we're still waiting for the user to enter a tag. if(!this._searchTags) return false; return super.hasNoResults; } get _searchTags() { return helpers.pixiv.getSearchTagsFromUrl(this.url); } /\x2f Return the search type from the URL. This is one of "artworks", "illustrations" /\x2f or "novels" (not supported). It can also be omitted, which is the "top" page, /\x2f but that gives the same results as "artworks" with a different page layout, so /\x2f we treat it as "artworks". get _searchType() { /\x2f ["", "tags", tag list, type] let url = helpers.pixiv.getUrlWithoutLanguage(this.url); let parts = url.pathname.split("/"); if(parts.length >= 4) return parts[3]; else return "artworks"; } startup() { super.startup(); /\x2f Refresh our title when translations are toggled. ppixiv.settings.addEventListener("disable-translations", this.cacheSearchTitle); } shutdown() { super.shutdown(); ppixiv.settings.removeEventListener("disable-translations", this.cacheSearchTitle); } cacheSearchTitle = async() => { this.title = "Search: "; let tags = this._searchTags; if(tags) { tags = await ppixiv.tagTranslations.translateTagList(tags, "en"); let tagList = document.createElement("vv-container"); for(let tag of tags) { /\x2f Force "or" lowercase. if(tag.toLowerCase() == "or") tag = "or"; let span = document.createElement("span"); span.innerText = tag; span.classList.add("word"); if(tag == "or") span.classList.add("or"); else if(tag == "(" || tag == ")") span.classList.add("paren"); else span.classList.add("tag"); tagList.appendChild(span); } this.title += tags.join(" "); this.displayingTags = tagList; } /\x2f Update our page title. this.callUpdateListeners(); } async loadPageInternal(page) { let args = { }; this.url.searchParams.forEach((value, key) => { args[key] = value; }); args.p = page; /\x2f "artworks" and "illustrations" are different on the search page: "artworks" uses "/tag/TAG/artworks", /\x2f and "illustrations" is "/tag/TAG/illustrations?type=illust_and_ugoira". let searchType = this._searchType; let searchMode = this.getUrlSearchMode(); let apiSearchType = null; if(searchMode == "all") { /\x2f "artworks" doesn't use the type field. apiSearchType = "artworks"; } else if(searchMode == "illust") { apiSearchType = "illustrations"; args.type = "illust_and_ugoira"; } else if(searchMode == "manga") { apiSearchType = "manga"; args.type = "manga"; } else if(searchMode == "ugoira") { apiSearchType = "illustrations"; args.type = "ugoira"; } else console.error("Invalid search type:", searchType); let tag = this._searchTags; /\x2f If we have no tags, we're probably on the "/tags" page, which is just a list of tags. Don't /\x2f run a search with no tags. if(!tag) { console.log("No search tags"); return; } let url = "/ajax/search/" + apiSearchType + "/" + encodeURIComponent(tag); let result = await helpers.pixivRequest.get(url, args); let body = result.body; /\x2f Store related tags. Only do this the first time and don't change it when we read /\x2f future pages, so the tags don't keep changing as you scroll around. if(this.relatedTags == null) { this.relatedTags = body.relatedTags; this.callUpdateListeners(); } /\x2f Add translations. let translations = []; for(let tag of Object.keys(body.tagTranslation)) { translations.push({ tag: tag, translation: body.tagTranslation[tag], }); } ppixiv.tagTranslations.addTranslations(translations); /\x2f /tag/TAG/illustrations returns results in body.illust. /\x2f /tag/TAG/artworks returns results in body.illustManga. /\x2f /tag/TAG/manga returns results in body.manga. let illusts = body.illust || body.illustManga || body.manga; illusts = illusts.data; /\x2f Populate thumbnail data with this data. let mediaIds = await ppixiv.mediaCache.addMediaInfosPartial(illusts, "normal"); return { mediaIds }; } get pageTitle() { return this.title; } getDisplayingText() { return this.displayingTags ?? "Search works"; }; /\x2f Return the search mode, which is selected by the "Type" search option. This generally /\x2f corresponds to the underlying page's search modes. getUrlSearchMode() { /\x2f "/tags/tag/illustrations" has a "type" parameter with the search type. This is used for /\x2f "illust" (everything except animations) and "ugoira". let searchType = this._searchType; if(searchType == "illustrations") { let querySearchType = this.url.searchParams.get("type"); if(querySearchType == "ugoira") return "ugoira"; if(querySearchType == "illust") return "illust"; /\x2f If there's no parameter, show everything. return "all"; } if(searchType == "artworks") return "all"; if(searchType == "manga") return "manga"; /\x2f Use "all" for unrecognized types. return "all"; } /\x2f Return URL with the search mode set to mode. setUrlSearchMode(url, mode) { url = new URL(url); url = helpers.pixiv.getUrlWithoutLanguage(url); /\x2f 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 searchType = "artworks"; if(mode == "manga") searchType = "manga"; else if(mode == "ugoira" || mode == "illust") searchType = "illustrations"; /\x2f Set the type in the URL. let parts = url.pathname.split("/"); parts[3] = searchType; url.pathname = parts.join("/"); return url; } } class UI extends Widget { constructor({ dataSource, ...options }) { super({ ...options, template: \`
\${ helpers.createBoxLink({label: "Ages", classes: ["ages-button"] }) } \${ helpers.createBoxLink({label: "Sort", classes: ["sort-button"] }) } \${ helpers.createBoxLink({label: "Type", classes: [["search-type-button"]] }) } \${ helpers.createBoxLink({label: "Search mode", classes: ["search-mode-button"] }) } \${ helpers.createBoxLink({label: "Image size", classes: ["image-size-button"] }) } \${ helpers.createBoxLink({label: "Aspect ratio", classes: ["aspect-ratio-button"] }) } \${ helpers.createBoxLink({label: "Bookmarks", classes: ["bookmark-count-button", "premium-only"] }) } \${ helpers.createBoxLink({label: "Time", classes: ["time-ago-button"] }) } \${ helpers.createBoxLink({label: "Hide AI", popup: "Show only R18 works", dataType: "hide-ai" }) } \${ helpers.createBoxLink({label: "Reset", popup: "Clear all search options", classes: ["reset-search"] }) }
\`}); this.dataSource = dataSource; this.dataSource.addEventListener("updated", () => this.refresh(), this._signal); class RelatedTagDropdown extends TagDropdownWidget { async refreshTags() { let tags = this.dataSource.relatedTags; if(tags == null) return; /\x2f Short circuit if the tag list isn't changing, since IndexedDB is really slow. if(this._currentTags != null && JSON.stringify(this._currentTags) == JSON.stringify(tags)) return; /\x2f Look up tag translations. let tagList = tags; let translatedTags = await ppixiv.tagTranslations.getTranslations(tagList, "en"); /\x2f Stop if the tag list changed while we were reading tag translations. if(tagList != tags) return; this._currentTags = tags; /\x2f Remove any old tag list and create a new one. helpers.html.removeElements(this.root); for(let tag of tagList) { let translatedTag = tag; if(translatedTags[tag]) translatedTag = translatedTags[tag]; let a = helpers.createBoxLink({ label: translatedTag, classes: ["tag-entry"], link: this.formatTagLink(tag), asElement: true, }); this.root.appendChild(a); a.dataset.tag = tag; } } formatTagLink(tag) { return helpers.getArgsForTagSearch(tag, ppixiv.plocation); } }; this.tagDropdown = new DropdownMenuOpener({ button: this.querySelector(".related-tags-button"), createDropdown: ({...options}) => new RelatedTagDropdown({ dataSource, ...options }), }); dataSource.setupDropdown(this.querySelector(".ages-button"), [{ createOptions: { label: "All", dataset: { default: true } }, setupOptions: { fields: {mode: null} }, }, { createOptions: { label: "All ages" }, setupOptions: { fields: {mode: "safe"} }, }, { createOptions: { label: "R18", classes: ["r18"] }, setupOptions: { fields: {mode: "r18"} }, }]); dataSource.setupDropdown(this.querySelector(".sort-button"), [{ createOptions: { label: "Newest", dataset: { default: true } }, setupOptions: { fields: {order: null}, defaults: {order: "date_d"} } }, { createOptions: { label: "Oldest" }, setupOptions: { fields: {order: "date"} } }, { createOptions: { label: "Popularity", classes: ["premium-only"] }, setupOptions: { fields: {order: "popular_d"} } }, { createOptions: { label: "Popular with men", classes: ["premium-only"] }, setupOptions: { fields: {order: "popular_male_d"} } }, { createOptions: { label: "Popular with women", classes: ["premium-only"] }, setupOptions: { fields: {order: "popular_female_d"} } }]); let urlFormat = "tags/tag/type"; dataSource.setupDropdown(this.querySelector(".search-type-button"), [{ createOptions: { label: "All", dataset: { default: true } }, setupOptions: { urlFormat, fields: {"/type": "artworks", type: null}, } }, { createOptions: { label: "Illustrations" }, setupOptions: { urlFormat, fields: {"/type": "illustrations", type: "illust"}, } }, { createOptions: { label: "Manga" }, setupOptions: { urlFormat, fields: {"/type": "manga", type: null}, } }, { createOptions: { label: "Animations" }, setupOptions: { urlFormat, fields: {"/type": "illustrations", type: "ugoira"}, } }]); dataSource.setItem(this.root, { type: "hide-ai", toggle: true, fields: {ai_type: "1"}, }); /\x2f Hide "Hide AI" if the user's global setting hides it. This API doesn't really /\x2f make sense, it would be a lot cleaner if the global setting just set the default. if(ppixiv.pixivInfo.hideAiWorks) this.root.querySelector(\`[data-type='hide-ai']\`).hidden = true; dataSource.setupDropdown(this.querySelector(".search-mode-button"), [{ createOptions: { label: "Tag", dataset: { default: true } }, setupOptions: { fields: {s_mode: null}, defaults: {s_mode: "s_tag"} }, }, { createOptions: { label: "Exact tag match" }, setupOptions: { fields: {s_mode: "s_tag_full"} }, }, { createOptions: { label: "Text search" }, setupOptions: { fields: {s_mode: "s_tc"} }, }]); dataSource.setupDropdown(this.querySelector(".image-size-button"), [{ createOptions: { label: "All", dataset: { default: true } }, setupOptions: { fields: {wlt: null, hlt: null, wgt: null, hgt: null} }, }, { createOptions: { label: "High-res" }, setupOptions: { fields: {wlt: 3000, hlt: 3000, wgt: null, hgt: null} }, }, { createOptions: { label: "Medium-res" }, setupOptions: { fields: {wlt: 1000, hlt: 1000, wgt: 2999, hgt: 2999} }, }, { createOptions: { label: "Low-res" }, setupOptions: { fields: {wlt: null, hlt: null, wgt: 999, hgt: 999} }, }]); dataSource.setupDropdown(this.querySelector(".aspect-ratio-button"), [{ createOptions: {label: "All", icon: "", dataset: { default: true } }, setupOptions: { fields: {ratio: null} }, }, { createOptions: {label: "Landscape", icon: "panorama" }, setupOptions: { fields: {ratio: "0.5"} }, }, { createOptions: {label: "Portrait", icon: "portrait" }, setupOptions: { fields: {ratio: "-0.5"} }, }, { createOptions: {label: "Square", icon: "crop_square" }, setupOptions: { fields: {ratio: "0"} }, }]); /\x2f The Pixiv search form shows 300-499, 500-999 and 1000-. That's not /\x2f really useful and the query parameters let us filter differently, so we /\x2f replace it with a more useful "minimum bookmarks" filter. dataSource.setupDropdown(this.querySelector(".bookmark-count-button"), [{ createOptions: { label: "All", dataType: "bookmarks-all", dataset: { default: true } }, setupOptions: { fields: {blt: null, bgt: null} }, }, { createOptions: { label: "100+", dataType: "bookmarks-100" }, setupOptions: { fields: {blt: 100, bgt: null} }, }, { createOptions: { label: "250+", dataType: "bookmarks-250" }, setupOptions: { fields: {blt: 250, bgt: null} }, }, { createOptions: { label: "500+", dataType: "bookmarks-500" }, setupOptions: { fields: {blt: 500, bgt: null} }, }, { createOptions: { label: "1000+", dataType: "bookmarks-1000" }, setupOptions: { fields: {blt: 1000, bgt: null} }, }, { createOptions: { label: "2500+", dataType: "bookmarks-2500" }, setupOptions: { fields: {blt: 2500, bgt: null} }, }, { createOptions: { label: "5000+", dataType: "bookmarks-5000" }, setupOptions: { fields: {blt: 5000, bgt: null} }, }]); /\x2f The time-ago dropdown has a custom layout, so create it manually. new DropdownMenuOpener({ button: this.querySelector(".time-ago-button"), createDropdown: ({...options}) => { let dropdown = new Widget({ ...options, template: \`
\${ helpers.createBoxLink({label: "All", dataType: "time-all", dataset: { default: true } }) } \${ helpers.createBoxLink({label: "This week", dataType: "time-week", dataset: { shortLabel: "Weekly" } }) } \${ helpers.createBoxLink({label: "This month", dataType: "time-month" }) } \${ helpers.createBoxLink({label: "This year", dataType: "time-year" }) }
\${ helpers.createBoxLink({label: "1", dataType: "time-years-ago-1", dataset: { shortLabel: "1 year" } }) } \${ helpers.createBoxLink({label: "2", dataType: "time-years-ago-2", dataset: { shortLabel: "2 years" } }) } \${ helpers.createBoxLink({label: "3", dataType: "time-years-ago-3", dataset: { shortLabel: "3 years" } }) } \${ helpers.createBoxLink({label: "4", dataType: "time-years-ago-4", dataset: { shortLabel: "4 years" } }) } \${ helpers.createBoxLink({label: "5", dataType: "time-years-ago-5", dataset: { shortLabel: "5 years" } }) } \${ helpers.createBoxLink({label: "6", dataType: "time-years-ago-6", dataset: { shortLabel: "6 years" } }) } \${ helpers.createBoxLink({label: "7", dataType: "time-years-ago-7", dataset: { shortLabel: "7 years" } }) } years ago
\`, }); /\x2f The time filter is a range, but I'm not sure what time zone it filters in /\x2f (presumably either JST or UTC). There's also only a date and not a time, /\x2f which means you can't actually filter "today", since there's no way to specify /\x2f which "today" you mean. So, we offer filtering starting at "this week", /\x2f and you can just use the default date sort if you want to see new posts. /\x2f For "this week", we set the end date a day in the future to make sure we /\x2f don't filter out posts today. dataSource.setItem(dropdown, { type: "time-all", fields: {scd: null, ecd: null} }); let formatDate = (date) => { return (date.getYear() + 1900).toFixed().padStart(2, "0") + "-" + (date.getMonth() + 1).toFixed().padStart(2, "0") + "-" + date.getDate().toFixed().padStart(2, "0"); }; let setDateFilter = (name, start, end) => { let startDate = formatDate(start); let endDate = formatDate(end); dataSource.setItem(dropdown, { type: name, fields: {scd: startDate, ecd: endDate} }); }; let tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); let lastWeek = new Date(); lastWeek.setDate(lastWeek.getDate() - 7); let lastMonth = new Date(); lastMonth.setMonth(lastMonth.getMonth() - 1); let lastYear = new Date(); lastYear.setFullYear(lastYear.getFullYear() - 1); setDateFilter("time-week", lastWeek, tomorrow); setDateFilter("time-month", lastMonth, tomorrow); setDateFilter("time-year", lastYear, tomorrow); for(let yearsAgo = 1; yearsAgo <= 7; ++yearsAgo) { let startYear = new Date(); startYear.setFullYear(startYear.getFullYear() - yearsAgo - 1); let endYear = new Date(); endYear.setFullYear(endYear.getFullYear() - yearsAgo); setDateFilter("time-years-ago-" + yearsAgo, startYear, endYear); } /\x2f The "reset search" button removes everything in the query except search terms, and resets /\x2f the search type. let box = this.querySelector(".reset-search"); let url = new URL(this.dataSource.url); let tag = helpers.pixiv.getSearchTagsFromUrl(url); url.search = ""; if(tag == null) url.pathname = "/tags"; else url.pathname = "/tags/" + encodeURIComponent(tag) + "/artworks"; box.href = url; return dropdown; }, }); /\x2f Create the tag dropdown for the search page input. this.tagSearchBox = new TagSearchBoxWidget({ container: this.querySelector(".tag-search-box-container") }); /\x2f Fill the search box with the current tag. /\x2f /\x2f Add a space to the end, so another tag can be typed immediately after focusing an existing search. let search = this.dataSource._searchTags; if(search) search += " "; this.querySelector(".tag-search-box .input-field-container > input").value = search; } refresh() { super.refresh(); helpers.html.setClass(this.querySelector(".related-tags-button"), "disabled", this.dataSource.relatedTags == null); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/search-illusts.js `), "/vview/sites/pixiv/data-sources/search-users.js": loadBlob("application/javascript", `import DataSource from '/vview/sites/data-source.js'; import Widget from '/vview/widgets/widget.js'; import { helpers } from '/vview/misc/helpers.js'; export default class DataSource_SearchUsers extends DataSource { get name() { return "search-users"; } get allowExpandingMangaPages() { return false; } async loadPageInternal(page) { if(!this.username) return; /\x2f This API only returns 10 results per page This search only seems useful for looking /\x2f for somebody specific, so just load the first page to prevent spamming the API. if(page > 1) return; /\x2f Use the mobile API for this. THe desktop site has no API and has to be scraped, and if /\x2f we're on mobile we can't access the desktop page, but the mobile site's API works either /\x2f way. let result = await helpers.pixivRequest.get("/touch/ajax/search/users", { nick: this.username, s_mode: "s_usr", p: page, lang: "en", }); if(result.error) { ppixiv.message.show("Error reading search: " + result.message); return; } /\x2f This returns images for each user, but that doesn't seem useful (this is a user search, /\x2f not discovery), and the format is different from everything else, so it's a bit of a pain /\x2f to use. Just return users. let mediaIds = []; for(let user of result.body.users) { ppixiv.extraCache.addQuickUserData({ userId: user.user_id, userName: user.user_name, profileImageUrl: user.profile_img.main, }); mediaIds.push(\`user:\${user.user_id}\`); } return { mediaIds }; } get username() { return this.url.searchParams.get("nick") ?? ""; } get ui() { return UI; } get hasNoResults() { /\x2f Don't display "No Results" while we're still waiting for the user to enter a search. if(!this.username) return false; return super.hasNoResults; } get pageTitle() { let search = this.username; if(search) return "Search users: " + search; else return "Search users"; } getDisplayingText() { return this.pageTitle; } } class UI extends Widget { constructor({ dataSource, ...options }) { super({ ...options, template: \` \`}); this.dataSource = dataSource; this.querySelector(".user-search-box .search-submit-button").addEventListener("click", this.submitUserSearch); helpers.inputHandler(this.querySelector(".user-search-box input.search-users"), this.submitUserSearch); this.querySelector(".search-users").value = dataSource.username; } /\x2f Handle submitting searches on the user search page. submitUserSearch = (e) => { let search = this.querySelector(".user-search-box input.search-users").value; let url = new URL("/search_user.php#ppixiv", ppixiv.plocation); url.searchParams.append("nick", search); url.searchParams.append("s_mode", "s_usr"); helpers.navigate(url); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/search-users.js `), "/vview/sites/pixiv/data-sources/series.js": loadBlob("application/javascript", `import DataSource from '/vview/sites/data-source.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f /user/#/series/# export default class DataSource_MangaPages extends DataSource { get name() { return "series"; } constructor(args) { super(args); this.seriesInfo = null; this.userInfo = null; /\x2f /user/#/series/# let url = new URL(this.url); url = helpers.pixiv.getUrlWithoutLanguage(url); let parts = url.pathname.split("/"); this.seriesId = parts[4]; } async loadPageInternal(page) { if(page != 1) return; let url = \`/ajax/series/\${this.seriesId}\`; let result = await helpers.pixivRequest.get(url, { p: page }); if(result.error) { ppixiv.message.show("Error reading series: " + result.message); return; } let { body } = result; /\x2f Add translations. let translations = []; for(let tag of Object.keys(body.tagTranslation)) { translations.push({ tag: tag, translation: body.tagTranslation[tag], }); } ppixiv.tagTranslations.addTranslations(translations); /\x2f Find the series and user in the results. this.seriesInfo = helpers.other.findById(body.illustSeries, "id", this.seriesId); this.userInfo = helpers.other.findById(body.users, "userId", this.seriesInfo.userId); /\x2f Refresh the title. this.callUpdateListeners(); /\x2f Register info. await ppixiv.mediaCache.addMediaInfosPartial(body.thumbnails.illust, "normal"); /\x2f Add each page on each post in the series, sorting by order. let mediaIds = []; let seriesPageInfo = body.page; let seriesPages = seriesPageInfo.series; seriesPages.sort((lhs, rhs) => lhs.order - rhs.order); for(let seriesPage of seriesPages) { let illustId = seriesPage.workId; let mediaId = helpers.mediaId.fromIllustId(illustId, 0); mediaIds.push(mediaId); } return { mediaIds }; } get pageTitle() { if(this.seriesInfo) return this.userInfo.name + " - " + this.seriesInfo.title; else return "Series"; } getDisplayingText() { if(this.seriesInfo) return this.userInfo.name + " - " + this.seriesInfo.title; else return "Series"; }; get uiInfo() { let headerStripURL = this.seriesInfo?.url; return { userId: this.userInfo?.userId, headerStripURL, } } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/series.js `), "/vview/sites/pixiv/data-sources/similar-illusts.js": loadBlob("application/javascript", `/\x2f bookmark_detail.php#recommendations=1 - Similar Illustrations /\x2f /\x2f We use this as an anchor page for viewing recommended illusts for an image, since /\x2f there's no dedicated page for this. import DataSource from '/vview/sites/data-source.js'; import { helpers } from '/vview/misc/helpers.js'; export default class DataSource_SimilarIllusts extends DataSource { get name() { return "related-illusts"; } get pageTitle() { return "Similar Illusts"; } getDisplayingText() { return "Similar Illustrations"; } get estimatedItemsPerPage() { return 60; } async _loadPageAsync(page, args) { /\x2f The first time we load a page, get info about the source illustration too, so /\x2f we can show it in the UI. if(!this.fetchedMediaInfo) { this.fetchedMediaInfo = true; /\x2f Don't wait for this to finish before continuing. let illustId = this.url.searchParams.get("illust_id"); let mediaId = helpers.mediaId.fromIllustId(illustId) ppixiv.mediaCache.getMediaInfo(mediaId).then((mediaInfo) => { this.mediaInfo = mediaInfo; this.callUpdateListeners(); }).catch((e) => { console.error(e); }); } return await super._loadPageAsync(page, args); } async loadPageInternal(page) { /\x2f Don't load more than one page. Related illusts for the same post generally /\x2f returns the same results, so if we load more pages we can end up making lots of /\x2f requests that give only one or two new images each, and end up loading up to /\x2f page 5 or 6 for just a few extra results. if(page > 1) return; /\x2f Get "mode" from the URL. If it's not present, use "all". let mode = this.url.searchParams.get("mode") || "all"; let result = await helpers.pixivRequest.get("/ajax/discovery/artworks", { sampleIllustId: this.url.searchParams.get("illust_id"), mode: mode, limit: this.estimatedItemsPerPage, lang: "en", }); /\x2f result.body.recommendedIllusts[].recommendMethods, recommendSeedIllustIds /\x2f has info about why it recommended it. let thumbs = result.body.thumbnails.illust; await ppixiv.mediaCache.addMediaInfosPartial(thumbs, "normal"); ppixiv.tagTranslations.addTranslationsDict(result.body.tagTranslation); let mediaIds = []; for(let thumb of thumbs) mediaIds.push(helpers.mediaId.fromIllustId(thumb.id)); return { mediaIds }; }; get uiInfo() { let imageUrl = null; let imageLinkUrl = null; if(this.mediaInfo) { imageLinkUrl = \`/artworks/\${this.mediaInfo.illustId}#ppixiv\`; imageUrl = this.mediaInfo.previewUrls[0]; } return { imageUrl, imageLinkUrl }; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/sites/pixiv/data-sources/similar-illusts.js `), "/vview/util/args.js": loadBlob("application/javascript", `export default class Args { constructor(url) { if(url == null) throw ValueError("url must not be null"); url = new URL(url, ppixiv.plocation); this.path = url.pathname; this.query = url.searchParams; let { path: hashPath, query: hash_query } = Args.getHashArgs(url); this.hash = hash_query; this.hashPath = hashPath; /\x2f History state is only available when we come from the current history state, /\x2f since URLs don't have state. this.state = { }; } /\x2f Return true if url is one of ours. static isPPixivUrl(url) { /\x2f If we're native, all URLs on this origin are ours. if(ppixiv.native) return new URL(url).origin == document.location.origin; else return url.hash.startsWith("#ppixiv"); } static getHashArgs(url) { if(!this.isPPixivUrl(url)) return { path: "", query: new URLSearchParams() }; /\x2f The hash looks like: /\x2f /\x2f #ppixiv/a/b/c?foo&bar /\x2f /\x2f /a/b/c is the hash path. foo&bar are the hash args. /\x2f Parse the hash of the current page as a path. For example, if /\x2f the hash is #ppixiv/foo/bar?baz, parse it as /ppixiv/foo/bar?baz. /\x2f The pathname portion of this (with /ppixiv removed) is the hash path, /\x2f and the query portion is the hash args. /\x2f /\x2f If the hash is #ppixiv/abcd, the hash path is "/abcd". /\x2f Remove #ppixiv: let hashPath = url.hash; if(hashPath.startsWith("#ppixiv")) hashPath = hashPath.substr(7); else if(hashPath.startsWith("#")) hashPath = hashPath.substr(1); /\x2f See if we have hash args. let idx = hashPath.indexOf('?'); let query = null; if(idx != -1) { query = hashPath.substr(idx+1); hashPath = hashPath.substr(0, idx); } /\x2f We encode spaces as + in the URL, but decodeURIComponent doesn't, so decode /\x2f that first. Actual '+' is always escaped as %2B. hashPath = hashPath.replace(/\\+/g, " "); hashPath = decodeURIComponent(hashPath); if(query == null) return { path: hashPath, query: new URLSearchParams() }; else return { path: hashPath, query: new URLSearchParams(query) }; } static encodeURLPart(regex, part) { return part.replace(regex, (c) => { /\x2f encodeURIComponent(sic) encodes non-ASCII characters. We don't need to. let ord = c.charCodeAt(0); if(ord >= 128) return c; /\x2f Regular URL escaping wants to escape spaces as %20, which is silly since /\x2f it's such a common character in filenames. Escape them as + instead, like /\x2f things like AWS do. The escaping is different, but it's still a perfectly /\x2f valid URL. Note that the API doesn't decode these, we only use it in the UI. if(c == " ") return "+"; let hex = ord.toString(16).padStart('0', 2); return "%" + hex; }); } /\x2f Both "encodeURI" and "encodeURIComponent" are wrong for encoding hashes. /\x2f The first doesn't escape ?, and the second escapes lots of things we /\x2f don't want to, like forward slash. static encodeURLHash(hash) { return Args.encodeURLPart(/[^A-Za-z0-9-_\\.!~\\*'()/:\\[\\]\\^#=&]/g, hash); } /\x2f This one escapes keys in hash parameters. This is the same as encodeURLHash, /\x2f except it also encodes = and &. static encodeHashParam(param) { return Args.encodeURLPart(/[^A-Za-z0-9-_\\.!~\\*'()/:\\[\\]\\^#]/g, param); } /\x2f Encode a URLSearchParams for hash parameters. /\x2f /\x2f We can use URLSearchParams.toString(), but that escapes overaggressively and /\x2f gives us nasty, hard to read URLs. There's no reason to escape forward slash /\x2f in query parameters. static encodeHashParams(params) { let values = []; for(let key of params.keys()) { let key_values = params.getAll(key); for(let value of key_values) { key = Args.encodeHashParam(key); value = Args.encodeHashParam(value); values.push(key + "=" + value); } } return values.join("&"); } /\x2f Return the args for the current page. static get location() { let result = new this(ppixiv.plocation); /\x2f Include history state as well. Make a deep copy, so changing this doesn't /\x2f modify history.state. result.state = JSON.parse(JSON.stringify(ppixiv.phistory.state)) || { }; return result; } get url() { let url = new URL(ppixiv.plocation); url.pathname = this.path; url.search = this.query.toString(); /\x2f Set the hash portion of url to args, as a ppixiv url. /\x2f /\x2f For example, if this.hashPath is "a/b/c" and this.hash is { a: "1", b: "2" }, /\x2f set the hash to #ppixiv/a/b/c?a=1&b=2. url.hash = ppixiv.native? "#":"#ppixiv"; if(this.hashPath != "") { if(!this.hashPath.startsWith("/")) url.hash += "/"; url.hash += Args.encodeURLHash(this.hashPath); } let hash_string = Args.encodeHashParams(this.hash); if(hash_string != "") url.hash += "?" + hash_string; return url; } toString() { return this.url.toString(); } /\x2f Helpers to get and set arguments which can be in either the query, /\x2f the hash or the path. Examples: /\x2f /\x2f get("page") - get the query parameter "page" /\x2f get("#page") - get the hash parameter "page" /\x2f get("/1") - get the first path parameter /\x2f set("page", 10) - set the query parameter "page" to "10" /\x2f set("#page", 10) - set the hash parameter "page" to "10" /\x2f set("/1", 10) - set the first path parameter to "10" /\x2f set("page", null) - remove the query parameter "page" get(key) { let hash = key.startsWith("#"); let path = key.startsWith("/"); if(hash || path) key = key.substr(1); if(path) return this.getPathnameSegment(parseInt(key)); let params = hash? this.hash:this.query; return params.get(key); } set(key, value) { let hash = key.startsWith("#"); let path = key.startsWith("/"); if(hash || path) key = key.substr(1); if(path) { this.set_pathname_segment(parseInt(key), value); return; } let params = hash? this.hash:this.query; if(value != null) params.set(key, value); else params.delete(key); } /\x2f Return the pathname segment with the given index. If the path is "/abc/def", "abc" is /\x2f segment 0. If idx is past the end, return null. getPathnameSegment(idx) { /\x2f The first pathname segment is always empty, since the path always starts with a slash. idx++; let parts = this.path.split("/"); if(idx >= parts.length) return null; return decodeURIComponent(parts[idx]); } /\x2f Set the pathname segment with the given index. If the path is "/abc/def", setting /\x2f segment 0 to "ghi" results in "/ghi/def". /\x2f /\x2f If idx is at the end, a new segment will be added. If it's more than one beyond the /\x2f end a warning will be printed, since this usually shouldn't result in pathnames with /\x2f empty segments. If value is null, remove the segment instead. set_pathname_segment(idx, value) { idx++; let parts = this.path.split("/"); if(value != null) { value = encodeURIComponent(value); if(idx < parts.length) parts[idx] = value; else if(idx == parts.length) parts.push(value); else console.warn(\`Can't set pathname segment \${idx} to \${value} past the end: \${this.toString()}\`); } else { if(idx == parts.length-1) parts.pop(); else if(idx < parts.length-1) console.warn(\`Can't remove pathname segment \${idx} in the middle: \${this.toString()}\`); } this.path = parts.join("/"); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/args.js `), "/vview/util/bezier.js": loadBlob("application/javascript", `/\x2f A simple bezier curve implementation matching cubic-bezier. import * as math from '/vview/util/math.js'; export default class Bezier2D { /\x2f Return a standard curve by name. static curve(name) { if(this._curves == null) { /\x2f Standard curves: this._curves = { "ease": new Bezier2D(0.25, 0.1, 0.25, 1.0), "linear": new Bezier2D(0.0, 0.0, 1.0, 1.0), "ease-in": new Bezier2D(0.42, 0, 1.0, 1.0), "ease-out": new Bezier2D(0, 0, 0.58, 1.0), "ease-in-out": new Bezier2D(0.42, 0, 0.58, 1.0), } } return this._curves[name]; } constructor(a, b, c, d) { /\x2f Store this first for debugging, so it shows up first in the inspector. this.originalData = [a,b,c,d]; this.X = new Quadratic(0, a, c, 1); this.Y = new Quadratic(0, b, d, 1); } GetXSlope(t) { return 3*this.X.A*t*t + 2*this.X.B*t + this.X.C; } evaluate(x) { /\x2f The range to search: let x_start = this.X.D; let x_end = this.X.A + this.X.B + this.X.C + this.X.D; /\x2f Search for the curve position of x on the X curve. let t = math.scale(x, x_start, x_end, 0, 1); for(let i = 0; i < 100; ++i) { let guess = this.X.evaluate(t); let error = x-guess; if(Math.abs(error) < 0.0001) break; /\x2f Improve our guess based on the curve slope. let slope = this.GetXSlope(t); t += error / slope; } return this.Y.evaluate(t); } /\x2f Find a bezier curve that roughly matches a given velocity. /\x2f /\x2f This is used when we're responding to a fling with an animation, and we want the /\x2f animation (usually a page turn) to have the same velocity as the fling. The end /\x2f of the curve is always an ease-out, and the beginning of the curve will ease depending /\x2f on the velocity. /\x2f /\x2f Returns a bezier-curve() string. static findCurveForVelocity({ /\x2f The desired velocity (usually in pixels/sec): targetVelocity, /\x2f The distance the animation will be travelling (usually in pixels): distance, /\x2f The duration the animation will be, in milliseconds: duration, }={}) { /\x2f We're searching from (0, 0.5, 0.5, 1), which eases in slowly: /\x2f https:/\x2fcubic-bezier.com/#0,.5,.5,1 /\x2f to (0.5, 0.5, 0.5, 1), which starts immediately: /\x2f https:/\x2fcubic-bezier.com/#.5,0,.5,1 /\x2f /\x2f This is just searching the angle of the start of the curve which changes continuously from /\x2f 0 to 0.5, so we can binary search this. This could probably be calculated directly without /\x2f searching. let min = 0, max = 0.5; while(max-min > 0.01) { let t = (max + min) / 2; let curve = new Bezier2D(t, 0.5-t, 0.5, 1); /\x2f Roughly estimate the velocity at the start of the curve by seeing how far we'd travel in the /\x2f first 60Hz frame. let sampleSeconds = 1/60; /\x2f one "frame" let segmentDistance = distance * curve.evaluate(sampleSeconds / (duration / 1000)); /\x2f distance travelled in sampleSeconds let actualDistancePerSecond = segmentDistance / sampleSeconds; /\x2f distance travelled in one second at that speed /\x2f Higher values give slower-starting curves. Adjust min if we're too fast, otherwise /\x2f adjust max. if(actualDistancePerSecond > targetVelocity) min = t; else max = t; } let t = (max + min) / 2; let curve = new Bezier2D(t, 0.5 - t, 0.45, 1.0); let easing = \`cubic-bezier(\${t}, \${0.5-t}, 0.45, 1)\`; return { curve, easing, t }; } } class Quadratic { constructor(X1, X2, X3, X4) { this.D = X1; this.C = 3.0 * (X2 - X1); this.B = 3.0 * (X3 - X2) - this.C; this.A = X4 - X1 - this.C - this.B; } evaluate(t) { /\x2f optimized (A * t*t*t) + (B * t*t) + (C * t) + D return ((this.A*t + this.B)*t + this.C)*t + this.D; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/bezier.js `), "/vview/util/crc32.js": loadBlob("application/javascript", `/\x2f pako/lib/zlib/crc32.js, MIT license: https:/\x2fgithub.com/nodeca/pako/ let crcTable = []; for(let n = 0; n < 256; n++) { let c = n; for(let k = 0; k < 8; k++) c = ((c&1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1)); crcTable[n] = c; } export default function crc32(buf) { let crc = 0 ^ (-1); for(let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ crcTable[(crc ^ buf[i]) & 0xFF]; return crc ^ (-1); /\x2f >>> 0; } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/crc32.js `), "/vview/util/fling-velocity.js": loadBlob("application/javascript", `/\x2f FlingVelocity takes input samples from pointer movements, and calculates velocity /\x2f and movement over time to calculate the direction and velocity of touch flings. export default class FlingVelocity { constructor({ samplePeriod=0.1 }={}) { this.samplePeriod = samplePeriod; this.reset(); } addSample( {x=0, y=0}={} ) { this.samples.push({ delta: { x, y }, time: Date.now()/1000, }); this._purge(); } /\x2f Delete samples older than samplePeriod. _purge() { let deleteBefore = Date.now()/1000 - this.samplePeriod; while(this.samples.length && this.samples[0].time < deleteBefore) this.samples.shift(); } /\x2f Delete all samples. reset() { this.samples = []; } /\x2f A helper to get currentDistance and currentVelocity in a direction: "up", "down", "left" or "right". getMovementInDirection(direction) { let distance = this.currentDistance; let velocity = this._getVelocityFromCurrentDistance(distance); switch(direction) { case "up": return { distance: -distance.y, velocity: -velocity.y }; case "down": return { distance: +distance.y, velocity: +velocity.y }; case "left": return { distance: -distance.x, velocity: -velocity.x }; case "right": return { distance: +distance.x, velocity: +velocity.x }; default: throw new Error("Unknown direction:", direction); } } /\x2f Get the distance travelled within the sample period. get currentDistance() { this._purge(); if(this.samples.length == 0) return { x: 0, y: 0 }; let total = [0,0]; for(let sample of this.samples) { total[0] += sample.delta.x; total[1] += sample.delta.y; } return { x: total[0], y: total[1] }; } /\x2f Get the average velocity. get currentVelocity() { return this._getVelocityFromCurrentDistance(this.currentDistance); } _getVelocityFromCurrentDistance(currentDistance) { let { x, y } = currentDistance; if(this.samples.length == 0) return { x: 0, y: 0 }; let duration = Date.now()/1000 - this.samples[0].time; if( duration < 0.001 ) { /\x2f console.error("no sample duration"); return { x: 0, y: 0 }; } x /= duration; y /= duration; return { x, y }; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/fling-velocity.js `), "/vview/util/gm-download.js": loadBlob("application/javascript", ` /\x2f If we're running as a user script, we may have access to GM.xmlHttpRequest. This is /\x2f sandboxed and exposed using a download port. The server side of this is inside /\x2f bootstrap.js. let _downloadPort = null; /\x2f Return a promise which resolves to the download MessagePort. function _getDownloadServer() { /\x2f If we already have a download port, return it. if(_downloadPort != null) return _downloadPort; _downloadPort = new Promise((accept, reject) => { /\x2f Send request-download-channel to window to ask the user script to send us the /\x2f GM.xmlHttpRequest message port. If this is handled and we can expect a response, /\x2f the event will be cancelled. let e = new Event("request-download-channel", { cancelable: true }); if(window.dispatchEvent(e)) { reject("GM.xmlHttpRequest isn't available"); return; } /\x2f The MessagePort will be returned as a message posted to the window. let receiveMessagePort = (e) => { if(e.data.cmd != "download-setup") return; window.removeEventListener("message", receiveMessagePort); _downloadPort = e.ports[0]; accept(e.ports[0]); }; window.addEventListener("message", receiveMessagePort); }); return _downloadPort; } /\x2f Download a Pixiv image using a GM.xmlHttpRequest server port retrieved /\x2f with _getDownloadServer. function _downloadUsingServer(serverPort, { url, ...args }) { return new Promise((accept, reject) => { if(url == null) { reject(null); return; } url = new URL(url); /\x2f Send a message to the sandbox to retrieve the image with GM.xmlHttpRequest, giving /\x2f it a message port to send the result back on. let { port1: serverResponsePort, port2: clientResponsePort } = new MessageChannel(); clientResponsePort.onmessage = (e) => { clientResponsePort.close(); if(e.data.success) accept(e.data.response); else reject(new Error(e.data.error)); }; serverPort.realPostMessage({ url: url.toString(), ...args, }, [serverResponsePort]); }); } /\x2f Download url, returning the data. /\x2f /\x2f This is only used to download Pixiv images to save to disk. Pixiv doesn't have CORS /\x2f set up to give itself access to its own images, so we have to use GM.xmlHttpRequest to /\x2f do this. export async function downloadPixivImage(url) { let server = await _getDownloadServer(); if(server == null) throw new Error("Downloading not available"); return await _downloadUsingServer(server, { url, headers: { "Cache-Control": "max-age=360000", Referer: "https:/\x2fwww.pixiv.net/", Origin: "https:/\x2fwww.pixiv.net/", }, }); } /\x2f Make a direct request to the download server. export async function sendRequest(args) { let server = await _getDownloadServer(); if(server == null) throw new Error("Downloading not available"); return await _downloadUsingServer(server, args); } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/gm-download.js `), "/vview/util/hooks.js": loadBlob("application/javascript", `/\x2f I use this path internally for testing and other things. It doesn't do anything interesting /\x2f for anyone else. export function init() { } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/hooks.js `), "/vview/util/html.js": loadBlob("application/javascript", `/\x2f Various self-contained helpers that work with HTML nodes. /\x2f Move all children of parent to newParent. export function moveChildren(parent, newParent) { for(let child of Array.from(parent.children)) { child.remove(); newParent.appendChild(child); } } /\x2f Remove all of parent's children. export function removeElements(parent) { for(let child of Array.from(parent.children)) child.remove(); } /\x2f Return true if ancestor is one of descendant's parents, or if descendant is ancestor. export function isAbove(ancestor, descendant) { console.assert(ancestor != null, "ancestor is null"); console.assert(descendant != null, "descendant is null"); while(descendant != null && descendant != ancestor) descendant = descendant.parentNode; return descendant == ancestor; } /\x2f Create a style node. export function createStyle(css, { id }={}) { let style = document.realCreateElement("style"); style.type = "text/css"; if(id) style.id = id; style.textContent = css; return style; } /\x2f Add a style node to the document. export function addStyle(name, css) { let style = this.createStyle(css); style.id = name; document.querySelector("head").appendChild(style); return style; } /\x2f Set or unset a class. export function setClass(element, className, enable) { if(element.classList.contains(className) == enable) return; if(enable) element.classList.add(className); else element.classList.remove(className); } /\x2f dataset is another web API with nasty traps: if you assign false or null to /\x2f it, it assigns "false" or "null", which are true values. export function setDataSet(dataset, name, value) { if(value) dataset[name] = value; else delete dataset[name]; } /\x2f Return the value of a list of CSS expressions. For example: /\x2f /\x2f getCSSValues({ value1: "calc(let(--value) * 2)" }); function getCSSValues(properties) { let div = document.createElement("div"); let style = []; for(let [key, value] of Object.entries(properties)) style += \`--\${key}:\${value};\\n\`; div.style = style; /\x2f The div needs to be in the document for this to work. document.body.appendChild(div); let computed = getComputedStyle(div); let results = {}; for(let key of Object.keys(properties)) results[key] = computed.getPropertyValue(\`--\${key}\`); div.remove(); return results; } /\x2f Get the current safe area insets. export function getSafeAreaInsets() { let { left, top, right, bottom } = getCSSValues({ left: 'env(safe-area-inset-left)', top: 'env(safe-area-inset-top)', right: 'env(safe-area-inset-right)', bottom: 'env(safe-area-inset-bottom)', }); left = parseInt(left ?? 0); top = parseInt(top ?? 0); right = parseInt(right ?? 0); bottom = parseInt(bottom ?? 0); return { left, top, right, bottom }; } /\x2f Sae the position of a scroller relative to the given node. The returned object can /\x2f be used with restoreScrollPosition. export function saveScrollPosition(scroller, saveRelativeTo) { return { originalScrollTop: scroller.scrollTop, originalOffsetTop: saveRelativeTo.offsetTop, }; } /\x2f Restore a scroll position saved with saveSCrollPosition. If given, restoreRelativeTo should /\x2f be a node corresponding to saveRelativeTo given to saveSCrollPosition. export function restoreScrollPosition(scroller, restoreRelativeTo, savedPosition) { let scrollTop = savedPosition.originalScrollTop; if(restoreRelativeTo) { let offset = restoreRelativeTo.offsetTop - savedPosition.originalOffsetTop; scrollTop += offset; } /\x2f Don't write to scrollTop if it's not changing, since that breaks /\x2f scrolling on iOS. if(scroller.scrollTop != scrollTop) scroller.scrollTop = scrollTop; } /\x2f If makeSVGUnique is false, skip making SVG IDs unique. This is a small optimization /\x2f for creating thumbs, which don't need this. export function createFromTemplate(template, {makeSVGUnique=true}={}) { let node = document.importNode(template.content, true).firstElementChild; if(makeSVGUnique) { /\x2f Make all IDs in the template we just cloned unique. for(let svg of node.querySelectorAll("svg")) makeSVGIdsUnique(svg); } return node; } /\x2f SVG has a big problem: it uses IDs to reference its internal assets, and that /\x2f breaks if you inline the same SVG more than once in a document. Making them unique /\x2f at build time doesn't help, since they break again as soon as you clone a template. /\x2f This makes styling SVGs a nightmare, since you can only style inlined SVGs. /\x2f /\x2f doesn't help, since that's just broken with masks and gradients entirely. /\x2f Broken for over a decade and nobody cares: https:/\x2fbugzilla.mozilla.org/show_bug.cgi?id=353575 /\x2f /\x2f This seems like a basic feature of SVG, and it's just broken. /\x2f /\x2f Work around it by making IDs within SVGs unique at runtime. This is called whenever /\x2f we clone SVGs. let _svgIdSequence = 0; function makeSVGIdsUnique(svg) { let idMap = {}; let idx = _svgIdSequence; /\x2f First, find all IDs in the SVG and change them to something unique. for(let def of svg.querySelectorAll("[id]")) { let oldId = def.id; let newId = def.id + "_" + idx; idx++; idMap[oldId] = newId; def.id = newId; } /\x2f Search for all URL references within the SVG and point them at the new IDs. for(let node of svg.querySelectorAll("*")) { for(let attr of node.getAttributeNames()) { let value = node.getAttribute(attr); let newValue = value; /\x2f See if this is an ID reference. We don't try to parse all valid URLs /\x2f here. Handle url(#abcd) inside strings, and things like xlink:xref="#abcd". if((attr == "href" || attr == "xlink:href") && value.startsWith("#")) { let oldId = value.substr(1); let newId = idMap[oldId]; if(newId == null) { console.warn("Unmatched SVG ID:", oldId); continue; } newValue = "#" + newId; } let re = /url\\(#.*?\\)/; newValue = newValue.replace(re, (str) => { let re = /url\\(#(.*)\\)/; let oldId = str.match(re)[1]; let newId = idMap[oldId]; if(newId == null) { console.warn("Unmatched SVG ID:", oldId); return str; } /\x2f Replace the ID. return "url(#" + newId + ")"; }); if(newValue != value) node.setAttribute(attr, newValue); } } /\x2f Store the index, so the next call will start with the next value. _svgIdSequence = idx; } /\x2f Set node's height as a CSS variable. /\x2f /\x2f If target is null, the variable is set on the node itself. export function setSizeAsProperty(node, { heightProperty, widthProperty, target, signal }={}) { if(target == null) target = node; let refreshSize = () => { /\x2f Our height usually isn't an integer. Round down, so we prefer to overlap backgrounds /\x2f with things like the video UI rather than leaving a gap. let { width, height } = node.getBoundingClientRect(); if(widthProperty) target.style.setProperty(widthProperty, \`\${Math.floor(width)}px\`); if(heightProperty) target.style.setProperty(heightProperty, \`\${Math.floor(height)}px\`); }; let resizeObserver = new ResizeObserver(() => refreshSize()); resizeObserver.observe(node); if(signal) signal.addEventListener("abort", () => resizeObserver.disconnect()); refreshSize(); } /\x2f Return the offset of element relative to an ancestor. export function getRelativePosition(element, ancestor) { let x = 0, y = 0; while(element != null && element != ancestor) { x += element.offsetLeft; y += element.offsetTop; /\x2f Advance through parents until we reach the offsetParent or the ancestor /\x2f that we're stopping at. We do this rather than advancing to offsetParent, /\x2f in case ancestor isn't an offsetParent. let searchFor = element.offsetParent; while(element != ancestor && element != searchFor) element = element.parentNode; } return [x, y]; } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/html.js `), "/vview/util/math.js": loadBlob("application/javascript", `/\x2f Scale x from [l1,h2] to [l2,h2]. export function scale(x, l1, h1, l2, h2) { return (x - l1) * (h2 - l2) / (h1 - l1) + l2; } /\x2f Clamp value between min and max. export function clamp(value, min, max) { if(min > max) [min, max] = [max, min]; return Math.min(Math.max(value, min), max); } /\x2f Scale x from [l1,h2] to [l2,h2], clamping to l2,h2. export function scaleClamp(x, l1, h1, l2, h2) { return clamp(scale(x, l1, h1, l2, h2), l2, h2); } /\x2f Return i rounded up to interval. export function roundUpTo(i, interval) { return Math.floor((i+interval-1)/interval) * interval; } export function distance({x: x1, y: y1}, {x: x2, y: y2}) { let distance = Math.pow(x1-x2, 2) + Math.pow(y1-y2, 2); return Math.pow(distance, 0.5); } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/math.js `), "/vview/util/media-id.js": loadBlob("application/javascript", `/\x2f Helpers for working with media IDs. /\x2f Encode a media ID. /\x2f /\x2f These represent single images, videos, etc. that we can view. Examples: /\x2f /\x2f illust:1234-0 - The first page of Pixiv illust ID 1234 /\x2f illust:1234-12 - Pixiv illust ID 1234, page 12. Pages are zero-based. /\x2f user:1000 - Pixiv user 1000. /\x2f folder:/images - A directory in the local API. /\x2f file:/images/image.jpg - A file in the local API. /\x2f /\x2f IDs with the local API are already in this format, and Pixiv illust IDs and pages are /\x2f converted to it. export function encodeMediaId({type, id, page=null}={}) { if(type == "illust") { if(page == null) page = 0; id += "-" + page; } return type + ":" + id; } /\x2f Media IDs are parsed by the thousands, and this can have a small performance /\x2f impact. Cache the results, so we only parse any given media ID once. let _mediaIdCache = new Map(); export function parse(mediaId) { let cache = _mediaIdCache.get(mediaId); if(cache == null) { cache = _parseMediaIdInner(mediaId); _mediaIdCache.set(mediaId, cache); } /\x2f Return a new object and not the cache, since the returned value might be /\x2f modified. return { type: cache.type, id: cache.id, page: cache.page }; } export function _parseMediaIdInner(mediaId) { /\x2f If this isn't an illust, a media ID is the same as an illust ID. let { type, id } = _splitId(mediaId); if(type != "illust") return { type: type, id: id, page: 0 }; /\x2f If there's no hyphen in the ID, it's also the same. if(mediaId.indexOf("-") == -1) return { type: type, id: id, page: 0 }; /\x2f Split out the page. let parts = id.split("-"); let page = parts[1]; page = parseInt(page); id = parts[0]; return { type: type, id: id, page: page }; } /\x2f Split a "type:id" into its two parts. /\x2f /\x2f If there's no colon, this is a Pixiv illust ID, so set type to "illust". function _splitId(id) { if(id == null) return { } let parts = id.split(":"); let type = parts.length < 2? "illust": parts[0]; let actual_id = parts.length < 2? id: parts.splice(1).join(":"); /\x2f join the rest return { type: type, id: actual_id, } } /\x2f Return a media ID from a Pixiv illustration ID and page number. export function fromIllustId(illustId, page) { if(illustId == null) return null; let { type, id } = _splitId(illustId); /\x2f Pages are only used for illusts. For other types, the page should always /\x2f be null or 0, and we don't include it in the media ID. if(type == "illust") { id += "-"; id += page || 0; } else { console.assert(page == null || page == 0); } return type + ":" + id; } /\x2f Convert a media ID to a Pixiv illust ID and manga page. export function toIllustIdAndPage(mediaId) { let { type, id, page } = parse(mediaId); if(type != "illust") return [mediaId, 0]; return [id, page]; } /\x2f Return true if mediaId is an ID for the local API. export function isLocal(mediaId) { let { type } = parse(mediaId); return type == "file" || type == "folder"; } /\x2f Given a media ID, return the same media ID for the first page. /\x2f /\x2f Some things don't interact with pages, such as illust info loads, and /\x2f only store data with the ID of the first page. export function getMediaIdFirstPage(mediaId) { return this.getMediaIdForPage(mediaId, 0); } export function getMediaIdForPage(mediaId, page=0) { if(mediaId == null) return null; let id = parse(mediaId); id.page = page; return encodeMediaId(id); } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/media-id.js `), "/vview/util/other.js": loadBlob("application/javascript", `/\x2f This holds a bunch of small helpers that don't have a better place to be. These /\x2f should be small, self-contained helpers that aren't too specific to the app, and /\x2f we shouldn't need to import anything here. /\x2f A small blank image as a data URL. export const blankImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; export const xmlns = "http:/\x2fwww.w3.org/2000/svg"; /\x2f Preload an array of images. export function preloadImages(images) { /\x2f We don't need to add the element to the document for the images to load, which means /\x2f we don't need to do a bunch of extra work to figure out when we can remove them. let preload = document.createElement("div"); for(let i = 0; i < images.length; ++i) { let img = document.createElement("img"); img.src = images[i]; preload.appendChild(img); } } export function defer(func) { return Promise.resolve().then(() => { func(); }); } export function sleep(ms, { signal=null }={}) { return new Promise((accept, reject) => { let timeout = null; let abort = () => { realClearTimeout(timeout); reject("aborted"); }; if(signal != null) signal.addEventListener("abort", abort, { once: true }); timeout = realSetTimeout(() => { if(signal) signal.removeEventListener("abort", abort, { once: true }); accept(); }, ms); }); } /\x2f Return a Promise with accept() and reject() available on the promise itself. /\x2f /\x2f This removes encapsulation, but is useful when using a promise like a one-shot /\x2f event where that isn't important. export function makePromise() { let accept, reject; let promise = new Promise((a, r) => { accept = a; reject = r; }); promise.accept = accept; promise.reject = reject; return promise; } export function makeFunction(value) { if(value instanceof Function) return value; else return () => value; } /\x2f Like Promise.all, but takes a dictionary of {key: promise}, returning a /\x2f dictionary of {key: result}. export async function awaitMap(map) { Promise.all(Object.values(map)); let results = {}; for(let [key, promise] of Object.entries(map)) results[key] = await promise; return results; } /\x2f setInterval using an AbortSignal to remove the interval. /\x2f /\x2f If callImmediately is true, call callback() now, rather than waiting /\x2f for the first interval. export function interval(callback, ms, signal, callImmediately=true) { if(signal && signal.aborted) return; let id = realSetInterval(callback, ms); if(signal) { /\x2f Clear the interval when the signal is aborted. signal.addEventListener("abort", () => { realClearInterval(id); }, { once: true }); } if(callImmediately) callback(); } /\x2f Return a promise that resolves when DOMContentLoaded has been received. export function waitForContentLoaded() { return new Promise((accept, reject) => { if(document.readyState != "loading") { accept(); return; } window.addEventListener("DOMContentLoaded", (e) => { accept(); }, { capture: true, once: true, }); }); } /\x2f Return a promise that waits for the given event on node. export function waitForEvent(node, name, { signal=null }={}) { return new Promise((resolve, reject) => { if(signal && signal.aborted) { resolve(null); return; } let removeListenersSignal = new AbortController(); node.addEventListener(name, (e) => { removeListenersSignal.abort(); resolve(e); }, { signal: removeListenersSignal.signal }); if(signal) { signal.addEventListener("abort",(e) => { removeListenersSignal.abort(); resolve("aborted"); }, { signal: removeListenersSignal.signal }); } }); } /\x2f Return a promise that waits for img to load. /\x2f /\x2f If img loads successfully, resolve with null. If signal is aborted, /\x2f resolve with "aborted". Otherwise, reject with "failed". This never /\x2f rejects. /\x2f /\x2f If we're aborted, img.src will be set to blankImage. Otherwise, /\x2f the image will load anyway. This is a little invasive, but it's what we /\x2f need to do any time we have a cancellable image load, so we might as well /\x2f do it in one place. export function waitForImageLoad(img, signal) { return new Promise((resolve, reject) => { let src = img.src; /\x2f Resolve immediately if the image is already loaded. if(img.complete) { resolve(null); return; } if(signal && signal.aborted) { img.src = blankImage; resolve("aborted"); return; } /\x2f Cancelling this controller will remove all of our event listeners. let removeListenersSignal = new AbortController(); img.addEventListener("error", (e) => { /\x2f We kept a reference to src in case in changes, so this log should /\x2f always point to the right URL. console.log("Error loading image:", src); removeListenersSignal.abort(); resolve("failed"); }, { signal: removeListenersSignal.signal }); img.addEventListener("load", (e) => { removeListenersSignal.abort(); resolve(null); }, { signal: removeListenersSignal.signal }); if(signal) { signal.addEventListener("abort",(e) => { img.src = blankImage; removeListenersSignal.abort(); resolve("aborted"); }, { signal: removeListenersSignal.signal }); } }); } /\x2f Wait until img.naturalWidth/naturalHeight are available. /\x2f /\x2f There's no event to tell us that img.naturalWidth/naturalHeight are /\x2f available, so we have to jump hoops. Loop using requestAnimationFrame, /\x2f since this lets us check quickly at a rate that makes sense for the /\x2f user's system, and won't be throttled as badly as setTimeout. export async function waitForImageDimensions(img, signal) { return new Promise((resolve, reject) => { if(signal && signal.aborted) resolve(false); if(img.naturalWidth != 0) resolve(true); let frame_id = null; /\x2f If signal is aborted, cancel our frame request. let abort = () => { signal.removeEventListener("aborted", abort); if(frame_id != null) realCancelAnimationFrame(frame_id); resolve(false); }; if(signal) signal.addEventListener("aborted", abort); let check = () => { if(img.naturalWidth != 0) { resolve(true); if(signal) signal.removeEventListener("aborted", abort); return; } frame_id = realRequestAnimationFrame(check); }; check(); }); } /\x2f Convert Canvas.toBlob to an async. export function canvasToBlob(canvas, { type="image/jpeg", quality=1, }) { return new Promise((resolve) => { canvas.toBlob((blob) => resolve(blob), type, quality); }); } /\x2f Wait up to ms for promise to complete. If the promise completes, return its /\x2f result, otherwise return "timed-out". export async function awaitWithTimeout(promise, ms) { let sleep = new Promise((accept, reject) => { realSetTimeout(() => { accept("timed-out"); }, ms); }); /\x2f Wait for whichever finishes first. return await Promise.any([promise, sleep]); } /\x2f Asynchronously wait for an animation frame. Return true on success, or false if /\x2f aborted by signal. export function vsync({signal=null}={}) { return new Promise((accept, reject) => { /\x2f The timestamp passed to the requestAnimationFrame callback is designed /\x2f incorrectly. It gives the time callbacks started being called, which is /\x2f meaningless. It should give the time in the future the current frame is /\x2f expected to be displayed, which is what you get from things like Android's /\x2f choreographer to allow precise frame timing. let id = null; let abort = () => { if(id != null) realCancelAnimationFrame(id); accept(false); }; /\x2f Stop if we're already aborted. if(signal?.aborted) { abort(); return; } id = realRequestAnimationFrame((time) => { if(signal) signal.removeEventListener("abort", abort); accept(true); }); if(signal) signal.addEventListener("abort", abort, { once: true }); }); } /\x2f Wait until the given WebSocket is opened. Return true on success or false on error. export function waitForWebSocketOpened(websocket, { signal }={}) { return new Promise((resolve) => { let cleanupAbort = new AbortController(); let onopen = (e) => { cleanupAbort.abort(); resolve(true); }; let onerror = (e) => { cleanupAbort.abort(); resolve(false); }; websocket.addEventListener("open", onopen, { signal: cleanupAbort.signal }); websocket.addEventListener("error", onerror, { signal: cleanupAbort.signal }); /\x2f If we were given a signal, stop if we're signalled as if the socket was closed. if(signal) signal.addEventHandler("abort", () => onclose(), { signal: cleanupAbort.signal }); }); } /\x2f Return the next WebSockets message. If the connection is closed, return null. export function waitForWebSocketMessage(websocket, { signal }={}) { return new Promise((resolve) => { let cleanupAbort = new AbortController(); let onmessage = (e) => { cleanupAbort.abort(); try { let data = JSON.parse(e.data); resolve(data); } catch(e) { console.log("Invalid data received from socket:", websocket); console.log(e.data); } }; let onclose = (e) => { cleanupAbort.abort(); resolve(null); }; websocket.addEventListener("message", onmessage, { signal: cleanupAbort.signal }); websocket.addEventListener("close", onclose, { signal: cleanupAbort.signal }); /\x2f If we were given a signal, stop if we're signalled as if the socket was closed. if(signal) signal.addEventHandler("abort", () => onclose(), { signal: cleanupAbort.signal }); }); } export function arrayEqual(lhs, rhs) { if(lhs.length != rhs.length) return false; for(let idx = 0; idx < lhs.length; ++idx) if(lhs[idx] != rhs[idx]) return false; return true; } /\x2f Return the index (in B) of the first value in A that exists in B. export function findFirstIdx(A, B) { for(let idx = 0; idx < A.length; ++idx) { let idx2 = B.indexOf(A[idx]); if(idx2 != -1) return idx2; } return -1; } /\x2f Return the index (in B) of the last value in A that exists in B. export function findLastIdx(A, B) { for(let idx = A.length-1; idx >= 0; --idx) { let idx2 = B.indexOf(A[idx]); if(idx2 != -1) return idx2; } return -1; } /\x2f Generate a UUID. export function createUuid() { let data = new Uint8Array(32); crypto.getRandomValues(data); /\x2f variant 1 data[8] &= 0b00111111; data[8] |= 0b10000000; /\x2f version 4 data[6] &= 0b00001111; data[6] |= 4 << 4; let result = ""; for(let i = 0; i < 4; ++i) result += data[i].toString(16).padStart(2, "0"); result += "-"; for(let i = 4; i < 6; ++i) result += data[i].toString(16).padStart(2, "0"); result += "-"; for(let i = 6; i < 8; ++i) result += data[i].toString(16).padStart(2, "0"); result += "-"; for(let i = 8; i < 10; ++i) result += data[i].toString(16).padStart(2, "0"); result += "-"; for(let i = 10; i < 16; ++i) result += data[i].toString(16).padStart(2, "0"); return result; } /\x2f JavaScript objects are ordered, but for some reason there's no way to actually manipulate /\x2f the order, such as adding to the beginning. We have to make a copy of the object, add /\x2f our new entry, then add everything else. export function addToBeginning(object, key, value) { let result = {}; result[key] = value; for(let [oldKey, oldValue] of Object.entries(object)) { if(oldKey != key) result[oldKey] = oldValue; } return result; } /\x2f Similar to addToBeginning, this adds at the end. Note that while addToBeginning returns a /\x2f new object, this edits the object in-place. We need to be careful with this, but it avoids making /\x2f a copy of the thumb dictionary every time we append to the end. To make it clearer that this /\x2f differs from addToBeginning, this doesn't return the object. export function addToEnd(object, key, value) { /\x2f Remove the key if it exists, so it's moved to the end. delete object[key]; object[key] = value; } export function shuffleArray(array) { for(let idx = 0; idx < array.length; ++idx) { let swap_with = Math.floor(Math.random() * array.length); [array[idx], array[swap_with]] = [array[swap_with], array[idx]]; } } /\x2f This is the same as Python's zip: /\x2f /\x2f for(let [a,b,c] of zip(array1, array2, array)) export function *zip(...args) { let iters = []; for(let arg of args) iters.push(arg[Symbol.iterator]()); while(1) { let values = []; for(let iter of iters) { let { value, done } = iter.next(); if(done) return; values.push(value); } yield values; } } /\x2f Return true if the screen is small enough for us to treat this as a phone. /\x2f /\x2f This is used for things like switching dialogs from a floating style to a fullscreen /\x2f style. export function isPhone() { /\x2f For now we just use an arbitrary threshold. return Math.min(window.innerWidth, window.innerHeight) < 500; } /\x2f Given a URLSearchParams, return a new URLSearchParams with keys sorted alphabetically. export function sortQueryParameters(search) { let searchKeys = Array.from(search.keys()); searchKeys.sort(); let result = new URLSearchParams(); for(let key of searchKeys) result.set(key, search.get(key)); return result; } export function arrayBufferToHex(data) { data = new Uint8Array(data); let hashArray = Array.from(data); let hash = hashArray.map((byte) => byte.toString(16).padStart(2, "0")); return hash.join(""); } /\x2f Find an item in a list with a given ID. export function findById(array, name, id) { for(let item of array) { if(item[name] == id) return item; } return null; } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/other.js `), "/vview/util/path.js": loadBlob("application/javascript", ` /\x2f Helpers for working with paths. export default class Path { /\x2f Return true if array begins with prefix. static _arrayStartsWith(array, prefix) { if(array.length < prefix.length) return false; for(let i = 0; i < prefix.length; ++i) if(array[i] != prefix[i]) return false; return true; } static isRelativeTo(path, root) { let pathParts = path.split("/"); let rootParts = root.split("/"); return Path._arrayStartsWith(pathParts, rootParts); } static splitPath(path) { /\x2f If the path ends with a slash, remove it. if(path.endsWith("/")) path = path.substr(0, path.length-1); let parts = path.split("/"); return parts; } /\x2f Return absolutePath relative to relativeTo. static getRelativePath(relativeTo, absolutePath) { console.assert(absolutePath.startsWith("/")); console.assert(relativeTo.startsWith("/")); let pathParts = Path.splitPath(absolutePath); let rootParts = Path.splitPath(relativeTo); /\x2f If absolutePath isn"t underneath relativeTo, leave it alone. if(!Path._arrayStartsWith(pathParts, rootParts)) return absolutePath; let relativeParts = pathParts.splice(rootParts.length); return relativeParts.join("/"); } /\x2f Append child to path. static getChild(path, child) { /\x2f If child is absolute, leave it alone. if(child.startsWith("/")) return child; let pathParts = Path.splitPath(path); let childParts = Path.splitPath(child); let combined = pathParts.concat(childParts); return combined.join('/'); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/path.js `), "/vview/util/pixiv-request.js": loadBlob("application/javascript", ` /\x2f The CSRF token and user ID that Pixiv sends with its API calls. csrfToken is an /\x2f ancient holdover from before CORS and doesn't seem to actually be checked by the /\x2f server, but we send it for consistency. let requestInfo = { csrfToken: null, userId: null, } /\x2f Set the request info to use for future Pixiv API calls. export function setPixivRequestInfo({csrfToken, userId}) { requestInfo.csrfToken = csrfToken; requestInfo.userId = userId; } export async function get(url, data, options) { let params = createSearchParams(data); let query = params.toString(); if(query != "") url += "?" + query; let result = await sendPixivRequest({ method: "GET", url: url, responseType: "json", signal: options?.signal, cache: options?.cache, headers: { Accept: "application/json", }, }); /\x2f If the result isn't valid JSON, we'll get a null result. if(result == null) result = { error: true, message: "Invalid response" }; return result; } function createSearchParams(data) { let params = new URLSearchParams(); for(let key in data) { /\x2f If this is an array, add each entry separately. This is used by /\x2f /ajax/user/#/profile/illusts. let value = data[key]; if(Array.isArray(value)) { for(let item of value) params.append(key, item); } else params.append(key, value); } return params; } export async function post(url, data) { let result = await sendPixivRequest({ "method": "POST", "url": url, "responseType": "json", "data" :JSON.stringify(data), "headers": { "Accept": "application/json", "Content-Type": "application/json; charset=utf-8", }, }); return result; } /\x2f Some API calls are form-encoded: export async function rpcPost(url, data) { let result = await sendPixivRequest({ method: "POST", url: url, data: encodeQuery(data), responseType: "json", headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", }, }); return result; } function encodeQuery(data) { let str = []; for(let key in data) { if(!data.hasOwnProperty(key)) continue; str.push(encodeURIComponent(key) + "=" + encodeURIComponent(data[key])); } return str.join("&"); } /\x2f Send a request with the referer, cookie and CSRF token filled in. export async function sendPixivRequest({...options}) { options.headers ??= {}; /\x2f Only set x-csrf-token for requests to www.pixiv.net. It's only needed for API /\x2f calls (not things like ugoira ZIPs), and the request will fail if we're in XHR /\x2f mode and set headers, since it'll trigger CORS. let hostname = new URL(options.url, window.location).hostname; if(hostname == "www.pixiv.net" && requestInfo.csrfToken) { options.headers["x-csrf-token"] = requestInfo.csrfToken; options.headers["x-user-id"] = requestInfo.userId; } let result = await sendRequest(options); if(result == null) return null; /\x2f Return the requested type. If we don't know the type, just return the /\x2f request promise itself. if(options.responseType == "json") { /\x2f Pixiv sometimes returns HTML responses to API calls on error, for example if /\x2f bookmark_add.php is called to follow a user without specifying recaptcha_enterprise_score_token. try { return await result.json(); } catch(e) { let message = \`\${result.status} \${result.statusText}\`; console.log(\`Couldn't parse API result for \${options.url}: \${message}\`); return { error: true, message }; } } if(options.responseType == "document") { let text = await result.text(); return new DOMParser().parseFromString(text, 'text/html'); } return result; } async function sendRequest(options) { if(options == null) options = {}; let data = { }; data.method = options.method || "GET"; data.signal = options.signal; data.cache = options.cache ?? "default"; if(options.data) data.body = options.data /\x2f Convert options.headers to a Headers object. if(options.headers) { let headers = new Headers(); for(let key in options.headers) headers.append(key, options.headers[key]); data.headers = headers; } let fetch = window.realFetch ?? window.fetch; try { return await fetch(options.url, data); } catch(e) { /\x2f Don't log an error if we were intentionally aborted. if(data.signal && data.signal.aborted) return null; console.error("Error loading %s", options.url, e); if(options.data) console.error("Data:", options.data); return null; } } /\x2f Load a URL as a document. export async function fetchDocument(url, headers={}, options={}) { return await this.sendPixivRequest({ method: "GET", url: url, responseType: "document", cache: options.cache, headers, ...options, }); } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/pixiv-request.js `), "/vview/util/pixiv.js": loadBlob("application/javascript", `/\x2f Return true if the given illust_data.tags contains the pixel art (ドット絵) tag. export function tagsContainDot(tagList) { if(tagList == null) return false; for(let tag of tagList) if(tag.indexOf("ドット") != -1) return true; return false; } /\x2f If a tag has a modifier, return [modifier, tag]. -tag seems to be the only one, so /\x2f we return ["-", "tag"]. export function splitTagPrefixes(tag) { if(tag[0] == "-") return ["-", tag.substr(1)]; else return ["", tag]; } /\x2f Some of Pixiv's URLs have languages prefixed and some don't. Ignore these and remove /\x2f them to make them simpler to parse. export function getPathWithoutLanguage(path) { if(/^\\/..\\/\x2f.exec(path)) return path.substr(3); else return path; } export function getUrlWithoutLanguage(url) { url.pathname = getPathWithoutLanguage(url.pathname); return url; } /\x2f Split a Pixiv tag search into a list of tags. export function splitSearchTags(search) { /\x2f Replace full-width spaces with regular spaces. Pixiv treats this as a delimiter. search = search.replace(" ", " "); /\x2f Make sure there's a single space around parentheses, so parentheses are treated as their own item. /\x2f This makes it easier to translate tags inside parentheses, and style parentheses separately. search = search.replace(/ *([\\(\\)]) */g, " \$1 "); /\x2f Remove repeated spaces. search = search.replace(/ +/g, " "); return search.split(" "); } /\x2f Find the real link inside Pixiv's silly jump.php links. export function fixPixivLink(link) { /\x2f These can either be /jump.php?url or /jump.php?url=url. let url = new URL(link); if(url.pathname != "/jump.php") return link; if(url.searchParams.has("url")) return url.searchParams.get("url"); else { let target = url.search.substr(1); /\x2f remove "?" target = decodeURIComponent(target); return target; } } export function fixPixivLinks(root) { for(let a of root.querySelectorAll("A[target='_blank']")) a.target = ""; for(let a of root.querySelectorAll("A")) { if(a.relList == null) a.rel += " noreferrer noopener"; /\x2f stupid Edge else { a.relList.add("noreferrer"); a.relList.add("noopener"); } } for(let a of root.querySelectorAll("A[href*='jump.php']")) a.href = fixPixivLink(a.href); } /\x2f Find all links to Pixiv pages, and set a #ppixiv anchor. /\x2f /\x2f This allows links to images in things like image descriptions to be loaded /\x2f internally without a page navigation. export function makePixivLinksInternal(root) { for(let a of root.querySelectorAll("A")) { let url = new URL(a.href, ppixiv.plocation); if(url.hostname != "pixiv.net" && url.hostname != "www.pixiv.net" || url.hash != "") continue; url.hash = "#ppixiv"; a.href = url.toString(); } } /\x2f Get the search tags from an "/en/tags/TAG" search URL. export function getSearchTagsFromUrl(url) { url = getUrlWithoutLanguage(url); let parts = url.pathname.split("/"); /\x2f ["", "tags", tag string, "search type"] let tags = parts[2] || ""; return decodeURIComponent(tags); } /\x2f From a URL like "/en/tags/abcd", return "tags". export function getPageTypeFromUrl(url) { url = new URL(url); url = getUrlWithoutLanguage(url); let parts = url.pathname.split("/"); return parts[1]; } /\x2f The inverse of getArgsForTagSearch: export function getTagSearchFromArgs(url) { url = getUrlWithoutLanguage(url); let type = getPageTypeFromUrl(url); if(type != "tags") return null; let parts = url.pathname.split("/"); return decodeURIComponent(parts[2]); } /\x2f Known image hosts. These can be selected in settings. export const pixivImageHosts = Object.freeze({ cf: { name: "CloudFlare", url: "i-cf.pximg.net" }, pixiv: { name: "Pixiv", url: "i.pximg.net" }, }); let _allPixivImageHosts = new Set(); for(let { url } of Object.values(pixivImageHosts)) { _allPixivImageHosts.add(url); } /\x2f Change the host for a Pixiv image URL from i.pximg.net to the user's configured /\x2f image host. export function adjustImageUrlHostname(url, { host=null }={}) { url = new URL(url); /\x2f Return the URL unchanged if it isn't a Pixiv image host. if(!_allPixivImageHosts.has(url.hostname)) { console.error(url); return url; } /\x2f If host is null, use the user's setting. if(host == null) host = ppixiv.settings.get("pixiv_cdn"); let hostname = pixivImageHosts[host]?.url ?? "i.pximg.net"; /\x2f Replace the hostname. url.hostname = hostname; return url; } /\x2f Given a low-res thumbnail URL from thumbnail data, return a high-res thumbnail URL. /\x2f If page isn't 0, return a URL for the given manga page. export function getHighResThumbnailUrl(url, page=0) { /\x2f Some random results on the user recommendations page also return this: /\x2f /\x2f /c/540x540_70/custom-thumb/img/.../12345678_custom1200.jpg /\x2f /\x2f Replace /custom-thumb/' with /img-master/ first, since it makes matching below simpler. url = url.replace("/custom-thumb/", "/img-master/"); /\x2f path should look like /\x2f /\x2f /c/250x250_80_a2/img-master/img/.../12345678_square1200.jpg /\x2f /\x2f where 250x250_80_a2 is the resolution and probably JPEG quality. We want /\x2f the higher-res thumbnail (which is "small" in the full image data), which /\x2f looks like: /\x2f /\x2f /c/540x540_70/img-master/img/.../12345678_master1200.jpg /\x2f /\x2f The resolution field is changed, and "square1200" is changed to "master1200". url = new URL(url, ppixiv.plocation); let path = url.pathname; let re = /(\\/c\\/)([^\\/]+)(.*)(square1200|master1200|custom1200).jpg/; let match = re.exec(path); if(match == null) { console.warn("Couldn't parse thumbnail URL:", path); return url.toString(); } url.pathname = match[1] + "540x540_70" + match[3] + "master1200.jpg"; if(page != 0) { /\x2f Manga URLs end with: /\x2f /\x2f /c/540x540_70/custom-thumb/img/.../12345678_p0_master1200.jpg /\x2f /\x2f p0 is the page number. url.pathname = url.pathname.replace("_p0_master1200", "_p" + page + "_master1200"); } return url.toString(); } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/pixiv.js `), "/vview/util/recaptcha.js": loadBlob("application/javascript", `import { helpers } from '/vview/misc/helpers.js'; let loadPromise = null; export function load() { if(loadPromise == null) loadPromise = loadInner(); return loadPromise; } async function loadInner() { /\x2f Pixiv only uses recaptcha for some users. Only load recaptcha if it's /\x2f actually needed. if(!ppixiv.pixivInfo?.pixivTests?.recaptcha_follow_user) return; if(!ppixiv.pixivInfo?.recaptchaKey) { console.warn("Pixiv requires recaptcha for this user, but we didn't get a recaptcha key"); return; } /\x2f Note that Pixiv may have already loaded recaptcha before we were able to stop the site /\x2f scripts from running. In principle we should be able to use the instance it created, but /\x2f for some reason it fails and requests time out at least in Firefox. Loading it a second /\x2f time seems harmless and seems to avoid this problem. console.log("Loading recaptcha"); let script = document.realCreateElement("script"); script.src = \`https:/\x2fwww.recaptcha.net/recaptcha/enterprise.js?render=\${ppixiv.pixivInfo.recaptchaKey}\`; document.head.appendChild(script); /\x2f Wait for it to load. await helpers.other.waitForEvent(script, "load"); script.remove(); /\x2f Wait for recaptcha to be ready. console.log("Waiting for recaptcha"); await waitForRecaptchaReady(); console.log("Recaptcha is ready"); /\x2f Send www/pageload on load like Pixiv does. Don't call getRecaptchaToken here, since /\x2f it'll deadlock waiting for us to complete. We don't need to wait for this to complete. window.grecaptcha.enterprise.execute(ppixiv.pixivInfo.recaptchaKey, { action: "www/pageload" }); } function waitForRecaptchaReady() { return new Promise((resolve) => { window.grecaptcha.enterprise.ready(() => resolve()); }); } export async function getRecaptchaToken(action) { /\x2f Make sure Recaptcha has finished loading. await load(); return await window.grecaptcha.enterprise.execute(ppixiv.pixivInfo.recaptchaKey, {action}); } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/recaptcha.js `), "/vview/util/strings.js": loadBlob("application/javascript", `/\x2f General string helpers. /\x2f Return the extension from a filename without the leading period. export function getExtension(fn) { let parts = fn.split("."); return parts[parts.length-1]; } /\x2f Format a Date as a date and time string. export function dateToString(date) { date = new Date(date); let day = date.toLocaleDateString(); let time = date.toLocaleTimeString(); return day + " " + time; } /\x2f Convert a string to title case. export function titleCase(s) { let parts = []; for(let part of s.split(" ")) parts.push(part.substr(0, 1).toUpperCase() + s.substr(1)); return parts.join(" "); } /\x2f Format a duration in seconds as MM:SS or HH:MM:SS. export function formatSeconds(totalSeconds) { totalSeconds = Math.floor(totalSeconds); let result = ""; let seconds = totalSeconds % 60; totalSeconds = Math.floor(totalSeconds / 60); let minutes = totalSeconds % 60; totalSeconds = Math.floor(totalSeconds / 60); let hours = totalSeconds % 24; result = \`\${minutes}:\${seconds.toString().padStart(2, '0')}\`; if(hours > 0) { /\x2f Pad minutes to two digits if we have hours. result = result.padStart(5, '0'); result = hours + ":" + result; } return result; } /\x2f Format an age in seconds as a string: /\x2f /\x2f 120 -> 2 mins /\x2f 7200 -> 2 hours export function ageToString(seconds) { /\x2f If seconds is negative, return a time in the future. let future = seconds < 0; if(future) seconds = -seconds; function to_plural(label, places, value) { let factor = Math.pow(10, places); let plural_value = Math.round(value * factor); if(plural_value > 1) label += "s"; let result = value.toFixed(places) + " " + label; result += future? " from now":" ago"; return result; }; if(seconds < 60) return to_plural("sec", 0, seconds); let minutes = seconds / 60; if(minutes < 60) return to_plural("min", 0, minutes); let hours = minutes / 60; if(hours < 24) return to_plural("hour", 0, hours); let days = hours / 24; if(days < 30) return to_plural("day", 0, days); let months = days / 30; if(months < 12) return to_plural("month", 0, months); let years = months / 12; return to_plural("year", 1, years); } /\x2f Parse: /\x2f 1 -> 1 /\x2f 1:2 -> 0.5 /\x2f null -> null /\x2f "" -> null export function parseRatio(value) { if(value == null || value == "") return null; if(value.indexOf == null) return value; let parts = value.split(":", 2); if(parts.length == 1) { return parseFloat(parts[0]); } else { let num = parseFloat(parts[0]); let den = parseFloat(parts[1]); return num/den; } } /\x2f Parse: /\x2f 1 -> [1,1] /\x2f 1...2 -> [1,2] /\x2f 1... -> [1,null] /\x2f ...2 -> [null,2] /\x2f 1:2 -> [0.5, 0.5] /\x2f 1:2...2 -> [0.5, 2] /\x2f null -> null export function parseRange(range) { if(range == null) return null; let parts = range.split("..."); let min = parseRatio(parts[0]); let max = parseRatio(parts[1]); return [min, max]; } /\x2f Return the last count parts of path. export function getPathSuffix(path, count=2, remove_from_end=0, { remove_extension=true }={}) { let parts = path.split('/'); parts = parts.splice(0, parts.length - remove_from_end); parts = parts.splice(parts.length-count); /\x2f take the last count parts let result = parts.join("/"); if(remove_extension) result = result.replace(/\\.[a-z0-9]+\$/i, ''); return result; } /\x2f Replace the given field in a URL path. /\x2f /\x2f If the path is "/a/b/c/d", "a" is 0 and "d" is 4. export function setPathPart(url, index, value) { url = new URL(url); /\x2f Split the path, and extend it if needed. let parts = url.pathname.split("/"); /\x2f The path always begins with a slash, so the first entry in parts is always empty. /\x2f Skip it. index++; /\x2f Hack: If this URL has a language prefixed, like "/en/users", add 1 to the index. This way /\x2f the caller doesn't need to check, since URLs can have these or omit them. if(parts.length > 1 && parts[1].length == 2) index++; /\x2f Extend the path if needed. while(parts.length < index) parts.push(""); parts[index] = value; /\x2f If the value is empty and this was the last path component, remove it. This way, we /\x2f remove the trailing slash from "/users/12345/". if(value == "" && parts.length == index+1) parts = parts.slice(0, index); url.pathname = parts.join("/"); return url; } export function getPathPart(url, index, value) { /\x2f The path always begins with a slash, so the first entry in parts is always empty. /\x2f Skip it. index++; let parts = url.pathname.split("/"); if(parts.length > 1 && parts[1].length == 2) index++; return parts[index] || ""; } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/strings.js `), "/vview/util/struct.js": loadBlob("application/javascript", `/\x2f https:/\x2fgithub.com/lyngklip/structjs/blob/master/struct.js /\x2f The MIT License (MIT) /\x2f Copyright (c) 2016 Aksel Jensen (TheRealAksel at github) /\x2f This is completely unreadable. Why would anyone write JS like this? /*eslint-env es6, node*/ 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") export default function 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 unpackFrom = (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 packInfo = (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) packInfo(b, 0, ...values) return b } const unpack = arrb => unpackFrom(arrb, 0) function* iterUnpack(arrb) { for (let offs = 0; offs + size <= arrb.byteLength; offs += size) { yield unpackFrom(arrb, offs); } } return Object.freeze({ unpack, pack, unpackFrom, packInfo, iterUnpack, format, size}) } /* const pack = (format, ...values) => struct(format).pack(...values) const unpack = (format, buffer) => struct(format).unpack(buffer) const packInfo = (format, arrb, offs, ...values) => struct(format).packInfo(arrb, offs, ...values) const unpackFrom = (format, arrb, offset) => struct(format).unpackFrom(arrb, offset) const iterUnpack = (format, arrb) => struct(format).iterUnpack(arrb) const calcsize = format => struct(format).size module.exports = { struct, pack, unpack, packInfo, unpackFrom, iterUnpack, calcsize } */ /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/struct.js `), "/vview/util/virtual-history.js": loadBlob("application/javascript", `/\x2f VirtualHistory is an implementation for document.location and window.history. It /\x2f does a couple things: /\x2f /\x2f It allows setting a temporary, virtual URL as the document location. This is used /\x2f by linked tabs to preview a URL without affecting browser history. /\x2f /\x2f Optionally, it can also replace browser history and navigation entirely. This is /\x2f used on mobile to work around some problems: /\x2f /\x2f - If there's any back or forwards history, it's impossible to disable the left and /\x2f right swipe gesture for browser back and forwards, even if you're running as a PWA, /\x2f and it's very easy to accidentally navigate back when you're trying to swipe up or /\x2f down at the edge of the screen. This eliminates them entirely on iOS. (Android /\x2f still has them, because Android's system gestures are broken.) /\x2f - iOS has a limit of 100 replaceState calls in 30 seconds. That doesn't make much /\x2f sense, since it's trivial for a regular person navigating quickly to reach that in /\x2f normal usage, and replaceState doesn't navigate the page so it shouldn't be limited /\x2f at all. /\x2f /\x2f We only enter this mode on mobile when we think we're running as a PWA without browser /\x2f UI. The main controller will handle intercepting clicks on links and redirecting them /\x2f here. If we're not doing this, this will only be used for virtual navigations. import Args from '/vview/util/args.js'; export default class VirtualHistory { constructor({ /\x2f If true, we're using this for all navigation and never using browser navigation. permanent=false }={}) { this.permanent = permanent; this.virtualUrl = null; /\x2f If we're in permanent mode, copy the browser state to our first history state. if(this.permanent) { this.history = []; this.history.push({ url: new URL(window.location), state: window.history.state }); /\x2f If we're permanent, we never expect to see popstate events coming from the /\x2f browser. Listen for these and warn about them. window.addEventListener("popstate", (e) => { if(e.isTrusted) console.warn("Unexpected popstate:", e); }, true); } /\x2f ppixiv.plocation can be accessed like document.location. Object.defineProperty(ppixiv, "plocation", { get: () => { /\x2f If we're not using a virtual location, return document.location. /\x2f Otherwise, return virtual_url. Always return a copy of virtual_url, /\x2f since the caller can modify it and it should only change through /\x2f explicit history changes. if(this.virtualUrl != null) return new URL(this.virtualUrl); if(!this.permanent) return new URL(document.location); return new URL(this._latestHistory.url); }, set: (value) => { /\x2f We could support assigning ppixiv.plocation, but we always explicitly /\x2f pushState. Just throw an exception if we get here accidentally. throw Error("Can't assign to ppixiv.plocation"); /* if(this.virtual) { /\x2f If we're virtual, replace the virtual URL. this.virtualUrl = new URL(value, this.virtualUrl); this.broadcastPopstate(); return; } if(!this.permanent) { document.location = value; return; } this.replaceState(null, "", value); this.broadcastPopstate(); */ }, }); } get virtual() { return this.virtualUrl != null; } get _latestHistory() { return this.history[this.history.length-1]; } urlIsVirtual(url) { /\x2f Push a virtual URL by putting #virtual=1 in the hash. let args = new Args(url); return args.hash.get("virtual"); } /\x2f Return the URL we'll go to if we go back. get previousStateUrl() { if(this.history.length < 2) return null; return this.history[this.history.length-2].url; } get previousStateArgs() { let url = this.previousStateUrl; if(url == null) return null; return new Args(url); } get length() { if(!this.permanent) return window.history.length; return this.history.length; } pushState(state, title, url) { url = new URL(url, document.location); let virtual = this.urlIsVirtual(url); if(virtual) { /\x2f We don't support a history of virtual locations. Once we're virtual, we /\x2f can only replaceState or back out to the real location. if(this.virtualUrl) throw Error("Can't push a second virtual location"); /\x2f Note that browsers don't dispatch popstate on pushState (which makes no sense at all), /\x2f so we don't here either to match. this._virtualState = state; this._virtualTitle = title; this.virtualUrl = url; return; } /\x2f We're pushing a non-virtual location, so we're no longer virtual if we were before. this.virtualUrl = null; if(!this.permanent) return window.history.pushState(state, title, url); this.history.push({ state, url }); this._updateBrowserState(); } replaceState(state, title, url) { url = new URL(url, document.location); let virtual = this.urlIsVirtual(url); if(virtual) { /\x2f We can only replace a virtual location with a virtual location. /\x2f We can't replace a real one with a virtual one, since we can't edit /\x2f history like that. if(this.virtualUrl == null) throw Error("Can't replace a real history entry with a virtual one"); this.virtualUrl = url; return; } /\x2f If we're replacing a virtual location with a real one, pop the virtual location /\x2f and push the new state instead of replacing. Otherwise, replace normally. if(this.virtualUrl != null) { this.virtualUrl = null; return this.pushState(state, title, url); } if(!this.permanent) return window.history.replaceState(state, title, url); this.history.pop(); this.history.push({ state, url }); this._updateBrowserState(); } get state() { if(this.virtual) return this._virtualState; if(!this.permanent) return window.history.state; return this._latestHistory.state; } set state(value) { if(this.virtual) this._virtualState = value; if(!this.permanent) window.history.state = value; this._latestHistory.state = value; } back() { /\x2f If we're backing out of a virtual URL, clear it to return to the real one. if(this.virtualUrl) { this.virtualUrl = null; this.broadcastPopstate({cause: "leaving-virtual"}); return; } if(!this.permanent) { window.history.back(); return; } if(this.history.length == 1) return; this.history.pop(); this.broadcastPopstate(); this._updateBrowserState(); } broadcastPopstate({cause}={}) { let e = new PopStateEvent("pp:popstate"); if(cause) e.navigationCause = cause; window.dispatchEvent(e); } /\x2f If we're permanent, we're not using the browser location ourself and we don't push /\x2f to browser history, but we do store the current URL and state, so the browser address /\x2f bar (if any) updates and we'll restore the latest state on reload if possible. _updateBrowserState() { if(!this.permanent) return; try { window.history.replaceState(this.state, "", this._latestHistory.url); } catch(e) { /\x2f iOS has a truly stupid bug: it thinks that casually flipping through pages more /\x2f than a few times per second (100 / 30 seconds) is something it should panic about, /\x2f and throws a SecurityError. console.log("Error setting browser history (ignored)", e); } } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/util/virtual-history.js `), "/vview/viewer/viewer-error.js": loadBlob("application/javascript", `import Viewer from '/vview/viewer/viewer.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f This is used to display muted images, and images that returned an error. export default class ViewerError extends Viewer { constructor({ ...options }={}) { super({...options, template: \`
\`}); this.root.querySelector(".view-muted-image").addEventListener("click", (e) => { /\x2f Add view-muted to the URL to override the mute for this image. let args = helpers.args.location; args.hash.set("view-muted", "1"); helpers.navigate(args, { addToHistory: false, cause: "override-mute" }); }); this.errorText = this.root.querySelector(".error-text"); /\x2f Just fire onready immediately for this viewer. this.ready.accept(true); } async load() { let { error, slideshow=false, onnextimage=() => { } } = this.options; /\x2f We don't skip muted images in slideshow immediately, since it could cause /\x2f API hammering if something went wrong, and most of the time slideshow is used /\x2f on bookmarks where there aren't a lot of muted images anyway. Just wait a couple /\x2f seconds and call onnextimage. if(slideshow && onnextimage) { let slideshowTimer = this._slideshowTimer = (async() => { await helpers.other.sleep(2000); if(slideshowTimer != this._slideshowTimer) return; onnextimage(this); })(); } /\x2f If we were given an error message, just show it. if(error) { console.log("Showing error view:", error); this.errorText.innerText = error; return; } /\x2f Show the user's avatar instead of the muted image. let mediaInfo = await ppixiv.mediaCache.getMediaInfo(this.mediaId); let userInfo = await ppixiv.userCache.getUserInfo(mediaInfo.userId); if(userInfo) { let img = this.root.querySelector(".muted-image"); img.src = userInfo.imageBig; } let mutedTag = ppixiv.muting.anyTagMuted(mediaInfo.tagList); let mutedUser = ppixiv.muting.isUserIdMuted(mediaInfo.userId); this.root.querySelector(".muted-label").hidden = false; this.root.querySelector(".view-muted-image").hidden = false; if(mutedTag) { let translatedTag = await ppixiv.tagTranslations.getTranslation(mutedTag); this.errorText.innerText = translatedTag; } else if(mutedUser) this.errorText.innerText = mediaInfo.userName; } shutdown() { super.shutdown(); this._slideshowTimer = null; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/viewer/viewer-error.js `), "/vview/viewer/viewer.js": loadBlob("application/javascript", `/\x2f This is the base class for viewer classes, which are used to view a particular /\x2f type of content in the main display. import Widget from '/vview/widgets/widget.js'; import { helpers } from '/vview/misc/helpers.js'; export default class Viewer extends Widget { constructor({mediaId, ...options}) { super(options); this.options = options; this.mediaId = mediaId; this.active = false; /\x2f This promise will be fulfilled with true once the viewer is displaying something, /\x2f so any previous viewer can be removed without flashing a blank screen. It'll be /\x2f fulfilled with false if we're shut down before that happens. this.ready = helpers.other.makePromise(); } shutdown() { this.ready.accept(false); super.shutdown(); } set active(value) { this._active = value; } get active() { return this._active; } /\x2f This is only called on mobile to handle double-tap to zoom. toggleZoom() { } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/viewer/viewer.js `), "/vview/viewer/images/desktop-viewer-images.js": loadBlob("application/javascript", `import ViewerImages from '/vview/viewer/images/viewer-images.js'; import PointerListener from '/vview/actors/pointer-listener.js'; import { HideMouseCursorOnIdle } from '/vview/misc/hide-mouse-cursor-on-idle.js'; import { ClassFlags } from '/vview/misc/helpers.js'; /\x2f This subclass implements our desktop pan/zoom UI. export default class ViewerImagesDesktop extends ViewerImages { constructor({...options}) { super(options); window.addEventListener("blur", (e) => this.stopDragging(), this._signal); this._pointerListener = new PointerListener({ element: this.root, buttonMask: 1, signal: this.shutdownSignal, callback: this._pointerevent, }); } _pointerevent = (e) => { if(e.mouseButton != 0 || this._slideshowMode) return; if(e.shiftKey) return; if(e.pressed && this.capturedPointerId == null) { e.preventDefault(); this.root.style.cursor = "none"; /\x2f Don't show the UI if the mouse hovers over it while dragging. ClassFlags.get.set("hide-ui", true); /\x2f Stop animating if this is a real click. If it's a carried-over click during quick /\x2f view, don't stop animating until we see a drag. if(e.type != "simulatedpointerdown") this._stopAnimation(); let zoomCenterPos; if(!this.getLockedZoom()) zoomCenterPos = this.getImagePosition([e.clientX, e.clientY]); /\x2f If this is a simulated press event, the button was pressed on the previous page, /\x2f probably due to quick view. Don't zoom from this press, but do listen to pointermove, /\x2f so sendMouseMovementToLinkedTabs is still called. let allowZoom = true; if(e.type == "simulatedpointerdown" && !this.getLockedZoom()) allowZoom = false; if(allowZoom) this._mousePressed = true; this._dragMovement = [0,0]; this.capturedPointerId = e.pointerId; this.root.setPointerCapture(this.capturedPointerId); this.root.addEventListener("lostpointercapture", this._lostPointerCapture, this._signal); /\x2f If this is a click-zoom, align the zoom to the point on the image that /\x2f was clicked. if(!this.getLockedZoom()) this.setImagePosition([e.clientX, e.clientY], zoomCenterPos); this._reposition(); /\x2f Only listen to pointermove while we're dragging. this.root.addEventListener("pointermove", this._pointermove, this._signal); } else { if(this.capturedPointerId == null || e.pointerId != this.capturedPointerId) return; /\x2f Tell HideMouseCursorOnIdle that the mouse cursor should be hidden, even though the /\x2f cursor may have just been moved. This prevents the cursor from appearing briefly and /\x2f disappearing every time a zoom is released. HideMouseCursorOnIdle.simulateInactivity(); this.stopDragging(); } } shutdown() { /\x2f Note that we need to avoid writing to browser history once shutdown() is called. ClassFlags.get.set("hide-ui", false); super.shutdown(); } stopDragging() { /\x2f Save our history state on mouseup. this._saveToHistory(); if(this.root != null) { this.root.removeEventListener("pointermove", this._pointermove); this.root.style.cursor = ""; } if(this.capturedPointerId != null) { this.root.releasePointerCapture(this.capturedPointerId); this.capturedPointerId = null; } this.root.removeEventListener("lostpointercapture", this._lostPointerCapture); ClassFlags.get.set("hide-ui", false); this._mousePressed = false; this._reposition(); } /\x2f If we lose pointer capture, clear the captured pointer_id. _lostPointerCapture = (e) => { if(e.pointerId == this.capturedPointerId) this.capturedPointerId = null; } _pointermove = (e) => { /\x2f Ignore pointermove events where the pointer didn't move, so we don't cancel /\x2f panning prematurely. Who designed an API where an event named "pointermove" /\x2f is used for button presses? if(e.movementX == 0 && e.movementY == 0) return; /\x2f If we're animating, only start dragging after we pass a drag threshold, so we /\x2f don't cancel the animation in quick view. These thresholds match Windows's /\x2f default SM_CXDRAG/SM_CYDRAG behavior. let { movementX, movementY } = e; /\x2f Unscale by devicePixelRatio, or movement will be faster if the browser is zoomed in. if(devicePixelRatio != null) { movementX /= devicePixelRatio; movementY /= devicePixelRatio; } this._dragMovement[0] += movementX; this._dragMovement[1] += movementY; if(this._animationsRunning && this._dragMovement[0] < 4 && this._dragMovement[1] < 4) return; this.applyPointerMovement({movementX, movementY}); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/viewer/images/desktop-viewer-images.js `), "/vview/viewer/images/editing-crop.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; import PointerListener from '/vview/actors/pointer-listener.js'; import ImageEditingOverlayContainer from '/vview/viewer/images/editing-overlay-container.js'; import { FixedDOMRect } from '/vview/misc/helpers.js'; export default class CropEditor extends Widget { constructor({...options}) { super({...options, template: \`
\`}); this.width = 1; this.height = 1; this._editorOverlay = this.root.querySelector(".crop-editor-overlay"); this._editorOverlay.remove(); this._currentCrop = null; this._editorOverlay.addEventListener("dblclick", this.ondblclick, { signal: this.shutdownSignal }); new PointerListener({ element: this._editorOverlay, callback: this.pointerevent, signal: this.shutdownSignal, }); this.box = this._editorOverlay.querySelector(".crop-box"); this.refresh(); } /\x2f Clear the crop on double-click. ondblclick = (e) => { e.preventDefault(); e.stopPropagation(); this.parent.saveUndo(); this._currentCrop = null; this.refresh(); } pointerevent = (e) => { if(!e.pressed) { e.preventDefault(); e.stopPropagation(); window.removeEventListener("pointermove", this.pointermove); /\x2f If the crop was inverted, fix it up now. this._currentCrop = this._effectiveCrop; return; } let clickedHandle = null; if(this._currentCrop == null) { let {x,y} = this.clientToContainerPos({ x: e.clientX, y: e.clientY }); this._currentCrop = new FixedDOMRect(x, y, x, y); clickedHandle = "bottomright"; } else clickedHandle = e.target.dataset.crop; if(clickedHandle == null) return; e.preventDefault(); e.stopPropagation(); this.parent.saveUndo(); /\x2f Which dimensions each handle moves: let dragParts = { all: "move", topleft: {y: "y1", x: "x1"}, top: {y: "y1"}, topright: {y: "y1", x: "x2"}, left: {x: "x1"}, right: {x: "x2"}, bottomleft: {y: "y2", x: "x1"}, bottom: { y: "y2" }, bottomright: { x: "x2", y: "y2" }, } window.addEventListener("pointermove", this.pointermove); this.dragging = dragParts[clickedHandle]; this._dragPos = this.clientToContainerPos({ x: e.clientX, y: e.clientY }); this.refresh(); } clientToContainerPos({x, y}) { let {width, height, top, left} = this._editorOverlay.getBoundingClientRect(); x -= left; y -= top; /\x2f Scale movement from client coordinates to the size of the container. x *= this.width / width; y *= this.height / height; return {x, y}; } pointermove = (e) => { /\x2f Get the delta in client coordinates. Don't use movementX/movementY, since it's /\x2f in screen pixels and will be wrong if the browser is scaled. let pos = this.clientToContainerPos({ x: e.clientX, y: e.clientY }); let delta = { x: pos.x - this._dragPos.x, y: pos.y - this._dragPos.y }; this._dragPos = pos; /\x2f Apply the drag. if(this.dragging == "move") { this._currentCrop.x += delta.x; this._currentCrop.y += delta.y; this._currentCrop.x = Math.max(0, this._currentCrop.x); this._currentCrop.y = Math.max(0, this._currentCrop.y); this._currentCrop.x = Math.min(this.width - this._currentCrop.width, this._currentCrop.x); this._currentCrop.y = Math.min(this.height - this._currentCrop.height, this._currentCrop.y); } else { let dragging = this.dragging; if(dragging.x != null) this._currentCrop[dragging.x] += delta.x; if(dragging.y != null) this._currentCrop[dragging.y] += delta.y; } this.refresh(); } /\x2f Return the current crop. If we're dragging, clean up the rectangle, making sure it /\x2f has a minimum size and isn't inverted. get _effectiveCrop() { /\x2f If we're not dragging, just return the current crop rectangle. if(this.dragging == null) return this._currentCrop; let crop = new FixedDOMRect( this._currentCrop.x1, this._currentCrop.y1, this._currentCrop.x2, this._currentCrop.y2, ); /\x2f Keep the rect from being too small. If the width is too small, push the horizontal /\x2f edge we're dragging away from the other side. if(this.dragging != "move") { let opposites = { x1: "x2", x2: "x1", y1: "y2", y2: "y1", } let minSize = 5; if(this.dragging.x != null && Math.abs(crop.width) < minSize) { let opposite_x = opposites[this.dragging.x]; if(crop[this.dragging.x] < crop[opposite_x]) crop[this.dragging.x] = crop[opposite_x] - minSize; else crop[this.dragging.x] = crop[opposite_x] + minSize; } if(this.dragging.y != null && Math.abs(crop.height) < minSize) { let opposite_y = opposites[this.dragging.y]; if(crop[this.dragging.y] < crop[opposite_y]) crop[this.dragging.y] = crop[opposite_y] - minSize; else crop[this.dragging.y] = crop[opposite_y] + minSize; } } /\x2f If we've dragged across the opposite edge, flip the sides back around. crop = new FixedDOMRect(crop.left, crop.top, crop.right, crop.bottom); /\x2f Clamp to the image bounds. crop = new FixedDOMRect( Math.max(crop.left, 0), Math.max(crop.top, 0), Math.min(crop.right, this.width), Math.min(crop.bottom, this.height), ); return crop; } refresh() { let box = this._editorOverlay.querySelector(".crop-box"); box.hidden = this._currentCrop == null; if(this._currentCrop == null) return; let crop = this._effectiveCrop; box.style.width = \`\${100 * crop.width / this.width}%\`; box.style.height = \`\${100 * crop.height / this.height}%\`; box.style.left = \`\${100 * crop.left / this.width}%\`; box.style.top = \`\${100 * crop.top / this.height}%\`; } setIllustData({replaceEditorData, extraData, width, height}) { if(extraData == null) return; this.width = width; this.height = height; this.box.setAttribute("viewBox", \`0 0 \${this.width} \${this.height}\`); if(replaceEditorData) this.setState(extraData.crop); this.refresh(); } set overlayContainer(overlayContainer) { console.assert(overlayContainer instanceof ImageEditingOverlayContainer); if(this._editorOverlay.parentNode) this._editorOverlay.remove(); overlayContainer.cropEditorOverlay = this._editorOverlay; this._overlayContainer = overlayContainer; } getDataToSave() { /\x2f If there's no crop, save an empty array to clear it. let state = this.getState(); return { crop: state, }; } async afterSave(mediaInfo) { /\x2f Disable cropping after saving, so the crop is visible. ppixiv.settings.set("image_editing_mode", null); } getState() { if(this._currentCrop == null) return null; let crop = this._effectiveCrop; return [ Math.round(crop.left), Math.round(crop.top), Math.round(crop.right), Math.round(crop.bottom), ] } setState(crop) { if(crop == null) this._currentCrop = null; else this._currentCrop = new FixedDOMRect(crop[0], crop[1], crop[2], crop[3]); this.refresh(); } visibilityChanged() { super.visibilityChanged(); this._editorOverlay.hidden = !this.visible; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/viewer/images/editing-crop.js `), "/vview/viewer/images/editing-inpaint.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; import ImageEditingOverlayContainer from '/vview/viewer/images/editing-overlay-container.js'; import PointerListener from '/vview/actors/pointer-listener.js'; import { helpers, KeyListener } from '/vview/misc/helpers.js'; export default class InpaintEditor extends Widget { constructor(options) { super({...options, template: \`
\${ helpers.createBoxLink({label: "View", classes: ["view-inpaint"] }) } \${ helpers.createBoxLink({label: "Create lines", classes: ["create-lines"] }) }
\`}); this.width = 100; this.height = 100; this.lines = []; this._downscaleRatio = 1; this._blur = 0; this._draggingSegmentPoint = -1; this._dragStart = null; this._selectedLineIdx = -1; this.ui = this.root.querySelector(".editor-buttons"); /\x2f Remove .inpaint-editor-overlay. It's inserted into the image overlay when we /\x2f have one, so it pans and zooms with the image. this._editorOverlay = this.root.querySelector(".inpaint-editor-overlay"); this._editorOverlay.remove(); this._svg = this._editorOverlay.querySelector(".inpaint-container"); this._createLinesButton = this.root.querySelector(".create-lines"); this._createLinesButton.addEventListener("click", (e) => { e.stopPropagation(); this.createLines = !this._createLines; }); /\x2f Update the selected line's thickness when the thickness slider changes. this._lineWidthSlider = this.root.querySelector(".inpaint-line-width"); this._lineWidthSliderBox = this.root.querySelector(".inpaint-line-width-box"); this._lineWidthSlider.addEventListener("input", (e) => { if(this._selectedLine == null) return; this._selectedLine.thickness = parseInt(this._lineWidthSlider.value); }); this._lineWidthSlider.value = ppixiv.settings.get("inpaint_default_thickness", 10); /\x2f Hide the inpaint while dragging the thickness slider. new PointerListener({ element: this._lineWidthSlider, callback: (e) => { this._overlayContainer.hideInpaint = e.pressed; }, }); this._downscaleSlider = this.root.querySelector(".inpaint-downscale"); this._downscaleSlider.addEventListener("change", (e) => { this.parent.saveUndo(); this.downscaleRatio = parseFloat(this._downscaleSlider.value); }, { signal: this.shutdownSignal }); this._blurSlider = this.root.querySelector(".inpaint-blur"); this._blurSlider.addEventListener("change", (e) => { this.parent.saveUndo(); this.blur = parseFloat(this._blurSlider.value); }, { signal: this.shutdownSignal }); let viewInpaintButton = this.root.querySelector(".view-inpaint"); new PointerListener({ element: viewInpaintButton, callback: (e) => { this.visible = !e.pressed; }, signal: this.shutdownSignal, }); /\x2f "Save default" buttons: this.root.querySelector(".save-default-thickness").addEventListener("click", (e) => { e.stopPropagation(); let value = parseInt(this._lineWidthSlider.value); ppixiv.settings.set("inpaint_default_thickness", value); console.log("Saved default line thickness:", value); }, { signal: this.shutdownSignal }); this.root.querySelector(".save-default-downscale").addEventListener("click", (e) => { e.stopPropagation(); let value = parseFloat(this._downscaleSlider.value); ppixiv.settings.set("inpaint_default_downscale", value); console.log("Saved default downscale:", value); }, { signal: this.shutdownSignal }); this.root.querySelector(".save-default-soften").addEventListener("click", (e) => { e.stopPropagation(); let value = parseFloat(this._blurSlider.value); ppixiv.settings.set("inpaint_default_blur", value); console.log("Saved default blur:", value); }, { signal: this.shutdownSignal }); new PointerListener({ element: this._editorOverlay, callback: this.pointerevent, signal: this.shutdownSignal, }); /\x2f This is a pain. We want to handle clicks when modifier buttons are pressed, and /\x2f let them through otherwise so panning works. Every other event system lets you /\x2f handle or not handle a mouse event and have it fall through if you don't handle /\x2f it, but CSS won't. Work around this by watching for our modifier keys and setting /\x2f pointer-events: none as needed. this._ctrlPressed = false; for(let modifier of ["Control", "Alt", "Shift"]) { new KeyListener(modifier, (pressed) => { this._ctrlPressed = pressed; this._refreshPointerEvents(); }, { signal: this.shutdownSignal }); } this._createLines = ppixiv.settings.get("inpaint_create_lines", false); /\x2f Prevent fullscreening if a UI element is double-clicked. this._editorOverlay.addEventListener("dblclick", this.ondblclick, { signal: this.shutdownSignal }); this._editorOverlay.addEventListener("mouseover", this.onmousehover, { signal: this.shutdownSignal }); this._refreshPointerEvents(); } shutdown() { super.shutdown(); /\x2f Clear lines when shutting down so we remove their event listeners. this.clear(); } /\x2f This is called when the ImageEditingOverlayContainer changes. set overlayContainer(overlayContainer) { console.assert(overlayContainer instanceof ImageEditingOverlayContainer) if(this._editorOverlay.parentNode) this._editorOverlay.remove(); overlayContainer.inpaintEditorOverlay = this._editorOverlay; this._overlayContainer = overlayContainer; } refresh() { super.refresh(); helpers.html.setClass(this._createLinesButton, "selected", this._createLines); if(this._selectedLine) this._lineWidthSlider.value = this._selectedLine.thickness; this._downscaleSlider.value = this._downscaleRatio; this._blurSlider.value = this.blur; } updateMenu(menuContainer) { let create = menuContainer.querySelector(".edit-inpaint"); helpers.html.setClass(create, "enabled", true); helpers.html.setClass(create, "selected", this.editor?._createLines); } visibilityChanged() { super.visibilityChanged(); this._editorOverlay.hidden = !this.visible; this.ui.hidden = !this.visible; } setIllustData({replaceEditorData, extraData, width, height}) { /\x2f Scale the thickness slider to the size of the image. let size = Math.min(width, height); this._lineWidthSlider.max = size / 25; if(replaceEditorData) { this.clear(); this.setState(extraData.inpaint); } if(extraData == null) return; /\x2f Match the size of the image. this._setSize(width, height); /\x2f If there's no data at all, load the user's defaults. if(extraData.inpaint == null) { this.downscaleRatio = ppixiv.settings.get("inpaint_default_downscale", 1); this.blur = ppixiv.settings.get("inpaint_default_blur", 0); } } getDataToSave() { return { inpaint: this.getState({forSaving: true}), } } async afterSave(mediaInfo) { if(mediaInfo.mangaPages[0] == null) return; if(mediaInfo.mangaPages[0].urls.inpaint) { /\x2f Saving the new inpaint data will change the inpaint URL. It'll be generated the first /\x2f time it's fetched, which can take a little while. Fetch it before updating image /\x2f data, so it's already generated when ViewerImages updates with the new URL. /\x2f Otherwise, we'll be stuck looking at the low-res preview while it generates. let img = new realImage(); img.src = mediaInfo.mangaPages[0].urls.inpaint; await helpers.other.waitForImageLoad(img); } return true; } /\x2f Return inpaint data for saving. /\x2f /\x2f If forSaving is true, return data to send to the server. This clears the /\x2f data entirely if there are no lines, so the inpaint data is removed entirely. /\x2f Otherwise, returns the full state, which is used for things like undo. getState({forSaving=false}={}) { if(forSaving && this.lines.length == 0) return null; let result = []; let settings = { } if(this._downscaleRatio != 1) settings.downscale = this._downscaleRatio; if(this.blur != 0) settings.blur = this.blur; if(Object.keys(settings).length > 0) { settings.action = "settings"; result.push(settings); } for(let line of this.lines) { let segments = []; for(let segment of line.segments) segments.push([segment[0], segment[1]]); let entry = { action: "line", thickness: line.thickness, line: segments, }; result.push(entry); } return result; } /\x2f Replace the inpaint data. setState(inpaint) { this.clear(); /\x2f Each entry looks like: /\x2f /\x2f [action: "settings", blur: 10, downscale: 2} /\x2f {action: "line", thickness: 10, line: [[1,1], [2,2], [3,3], [4,4], ...]} for(let part of inpaint || []) { let cmd = part.action; switch(cmd) { case "settings": if(part.downscale) this.downscaleRatio = parseFloat(part.downscale); if(part.blur) this.blur = parseFloat(part.blur); break; case "line": let line = this.addLine(); if(part.thickness) line.thickness = part.thickness; for(let point of part.line || []) line.addPoint({x: point[0], y: point[1]}); break; default: console.error("Unknown inpaint command:", cmd); break; } } this.refresh(); } get downscaleRatio() { return this._downscaleRatio; } set downscaleRatio(value) { if(this._downscaleRatio == value) return; this._downscaleRatio = value; this.refresh(); } get blur() { return this._blur; } set blur(value) { if(this._blur == value) return; this._blur = value; this.refresh(); } clear() { while(this.lines.length) this.removeLine(this.lines[0]); this.downscaleRatio = 1; this._blur = 0; } onmousehover = (e) => { let over = e.target.closest(".inpaint-line, .inpaint-handle") != null; this._overlayContainer.hideInpaint = over; /\x2f While we think we're hovering, add a mouseover listener to window, so we catch /\x2f all mouseover events that tell us we're no longer hovering. If we don't do this, /\x2f we won't see any event if the element that's being hovered is removed from the /\x2f document while it's being hovered. if(over) window.addEventListener("mouseover", this.onmousehover, { signal: this.shutdownSignal }); else window.removeEventListener("mouseover", this.onmousehover, { signal: this.shutdownSignal }); } get createLines() { return this._createLines; } set createLines(value) { if(this._createLines == value) return; this._createLines = value; ppixiv.settings.set("inpaint_create_lines", this._createLines); this._refreshPointerEvents(); /\x2f If we're turning quick line creation off and we have an incomplete line, /\x2f delete it. if(!this._createLines && this._addingLine) { this.removeLine(this._addingLine); this._addingLine = null; } this.refresh(); } _refreshPointerEvents() { helpers.html.setClass(this._editorOverlay, "creating-lines", this._createLines); if(this._ctrlPressed || this._createLines) this._editorOverlay.style.pointerEvents = "auto"; else this._editorOverlay.style.pointerEvents = "none"; } _getControlPointFromElement(node) { let inpaintSegment = node.closest(".inpaint-segment"); if(inpaintSegment) inpaintSegment = Widget.fromNode(inpaintSegment); let controlPoint = node.closest("[data-type='control-point']"); let inpaintLine = node.closest(".inpaint-line"); if(inpaintSegment == null) return { }; let controlPointIdx = controlPoint? parseInt(controlPoint.dataset.idx):-1; let inpaintLineIdx = inpaintLine? parseInt(inpaintLine.dataset.idx):-1; /\x2f If we're on an inpaint segment we should always have a point or line. If we /\x2f don't for some reason, ignore the segment too. if(controlPointIdx == -1 && inpaintLineIdx == -1) inpaintSegment = null; return { inpaintSegment, controlPointIdx, inpaintLineIdx }; } pointerevent = (e) => { let { x, y } = this.getPointFromClick(e); let { inpaintSegment, controlPointIdx, inpaintLineIdx } = this._getControlPointFromElement(e.target); this._selectedLine = inpaintSegment; /\x2f Check if we're in the middle of adding a line. Don't do this if the /\x2f same point was clicked (fall through and allow moving the point). if(e.pressed && this._addingLine != null && (inpaintSegment == null || inpaintSegment != this._addingLine)) { e.preventDefault(); e.stopPropagation(); if(inpaintSegment == this._addingLine) return; this.parent.saveUndo(); /\x2f If another segment was clicked while adding a line, connect to that line. if(inpaintSegment && controlPointIdx != -1) { /\x2f We can only connect to the beginning or end. Connect to whichever end is /\x2f closer to the point thta was clicked. let pointIdx = 0; if(controlPointIdx >= inpaintSegment.segments.length/2) pointIdx = inpaintSegment.segments.length; let point = this._addingLine.segments[0]; this.removeLine(this._addingLine); this._addingLine = null; inpaintSegment.addPoint({x: point[0], y: point[1], at: pointIdx}); /\x2f Drag the point we connected to, not the new point. this._startDraggingPoint(inpaintSegment, controlPointIdx, e); return; } let newControlPointIdx = this._addingLine.addPoint({x: x, y: y}); this._startDraggingPoint(this._addingLine, newControlPointIdx, e); this._addingLine = null; return; } if(e.pressed && inpaintSegment) { e.preventDefault(); e.stopPropagation(); this.parent.saveUndo(); /\x2f If shift is held, clicking a line segment inserts a point. Otherwise, it /\x2f drags the whole segment. if(controlPointIdx == -1 && e.shiftKey) { let { x, y } = this.getPointFromClick(e); controlPointIdx = inpaintSegment.addPoint({x: x, y: y, at: inpaintLineIdx}); } this._startDraggingPoint(inpaintSegment, controlPointIdx, e); return; } else if(this._draggingSegment && !e.pressed) { /\x2f We released dragging a segment. this._draggingSegmentPoint = -1; window.removeEventListener("pointermove", this._pointermoveDragPoint); } /\x2f If we're in create line mode, create points on click. if(e.pressed && this._createLines) { e.preventDefault(); e.stopPropagation(); this.parent.saveUndo(); this._addingLine = this.addLine(); this._addingLine.thickness = ppixiv.settings.get("inpaint_default_thickness", 10); let controlPointIdx = this._addingLine.addPoint({x: x, y: y}); this._startDraggingPoint(this._addingLine, controlPointIdx, e); } } _startDraggingPoint(inpaintSegment, pointIdx=-1, e) { this._draggingSegment = inpaintSegment; this._draggingSegmentPoint = pointIdx; this._dragPos = [e.clientX, e.clientY]; window.addEventListener("pointermove", this._pointermoveDragPoint); } /\x2f Convert a click from client coordinates to image coordinates. getPointFromClick({clientX, clientY}) { let {width, height, top, left} = this._editorOverlay.getBoundingClientRect(); let x = (clientX - left) / width * this.width; let y = (clientY - top) / height * this.height; return { x: x, y: y }; } ondblclick = (e) => { /\x2f Block double-clicks to stop ScreenIllust from toggling fullscreen. e.stopPropagation(); /\x2f Delete segments and points on double-click. let { inpaintSegment, controlPointIdx } = this._getControlPointFromElement(e.target); if(inpaintSegment) { this.parent.saveUndo(); if(controlPointIdx == -1) this.removeLine(inpaintSegment); else { inpaintSegment.removePoint(controlPointIdx); /\x2f If only one point is left, delete the segment. if(inpaintSegment.segments.length < 2) this.removeLine(inpaintSegment); } } } _pointermoveDragPoint = (e) => { /\x2f Get the delta in client coordinates. Don't use movementX/movementY, since it's /\x2f in screen pixels and will be wrong if the browser is scaled. let delta_x = e.clientX - this._dragPos[0]; let delta_y = e.clientY - this._dragPos[1]; this._dragPos = [e.clientX, e.clientY]; /\x2f Scale movement from client coordinates to the size of the container. let {width, height} = this._editorOverlay.getBoundingClientRect(); delta_x *= this.width / width; delta_y *= this.height / height; /\x2f Update the control points we're editing. If _draggingSegmentPoint is -1, update /\x2f the whole segment, otherwise update just that control point. let segments = this._draggingSegment.segments; for(let idx = 0; idx < segments.length; ++idx) { if(this._draggingSegmentPoint != -1 && this._draggingSegmentPoint != idx) continue; let segment = segments[idx]; segment[0] += delta_x; segment[1] += delta_y; /\x2f Clamp the position so it doesn't go offscreen. segment[0] = helpers.math.clamp(segment[0], 0, this.width); segment[1] = helpers.math.clamp(segment[1], 0, this.height); } this._draggingSegment.updateSegment(); } addLine() { let line = new LineEditorSegment({ container: this._svg, }); this.lines.push(line); this._refreshLines(); return line; } removeLine(line) { line.root.remove(); let idx = this.lines.indexOf(line); console.assert(idx != -1); /\x2f Deselect the line if it's selected. if(this._selectedLineIdx == idx) this._selectedLine = null; if(this._addingLine == line) this._addingLine = null; this.lines.splice(idx, 1); this._refreshLines(); } set _selectedLine(line) { if(line == null) this._selectedLineIdx = -1; else this._selectedLineIdx = this.lines.indexOf(line); this._refreshLines(); this.refresh(); } get _selectedLine() { if(this._selectedLineIdx == -1) return null; return this.lines[this._selectedLineIdx]; } _refreshLines() { for(let idx = 0; idx < this.lines.length; ++idx) { let line = this.lines[idx]; if(idx == this._selectedLineIdx) line.root.classList.add("selected"); else line.root.classList.remove("selected"); } } _setSize(width, height) { this.width = width; this.height = height; this._svg.setAttribute("viewBox", \`0 0 \${this.width} \${this.height}\`); } } class LineEditorSegment extends Widget { constructor({...options}) { super({ ...options, template: \` \` }); this._editPoints = []; this._thickness = 15; this.segments = []; this.segmentLines = []; this.segmentContainer = this.querySelector(".inpaint-segment"); this.createEditPoints(); } get thickness() { return this._thickness; } set thickness(value) { this._thickness = value; this.createEditPoints(); } addPoint({x, y, at=-1}) { let newSegment = [x, y]; if(at == -1) at = this.segments.length; this.segments.splice(at, 0, newSegment); this.createEditPoints(); return at; } removePoint(idx) { console.assert(idx < this.segments.length); this.segments.splice(idx, 1); this.createEditPoints(); } createEditPoint() { let point = document.createElementNS(helpers.other.xmlns, "ellipse"); point.setAttribute("class", "inpaint-handle"); point.setAttribute("cx", "100"); point.setAttribute("cy", "100"); point.setAttribute("rx", "10"); point.setAttribute("ry", "10"); return point; } createEditPoints() { for(let line of this.segmentLines) line.remove(); for(let point of this._editPoints) point.remove(); this.segmentLines = []; this._editPoints = []; if(!this.polyline) { this.polyline = document.createElementNS(helpers.other.xmlns, "polyline"); this.polyline.setAttribute("class", "inpaint-line"); this.segmentContainer.appendChild(this.polyline); } if(0) for(let idx = 0; idx < this.segments.length-1; ++idx) { /\x2f Use a rect for the lines. It doesn't join as cleanly as a polyline, /\x2f but it lets us set both the fill and the stroke. let line = document.createElementNS(helpers.other.xmlns, "rect"); line.setAttribute("class", "inpaint-line"); line.dataset.idx = idx; this.segmentContainer.appendChild(line); this.segmentLines.push(line); } for(let idx = 0; idx < this.segments.length; ++idx) { let point = this.createEditPoint(); point.dataset.type = "control-point"; point.dataset.idx = idx; this._editPoints.push(point); this.segmentContainer.appendChild(point); } this.updateSegment(); } /\x2f Update the line and control points when they've moved. updateSegment() { let points = []; for(let point of this.segments) points.push(\`\${point[0]},\${point[1]}\`); this.polyline.setAttribute("points", points.join(" ")); this.polyline.setAttribute("stroke-width", this._thickness); if(0) for(let idx = 0; idx < this.segments.length-1; ++idx) { let line = this.segmentLines[idx]; let p0 = this.segments[idx]; let p1 = this.segments[idx+1]; let length = Math.pow(p0[0]-p1[0], 2) + Math.pow(p0[1]-p1[1],2); length = Math.sqrt(length); let angle = Math.atan2(p1[1]-p0[1], p1[0]-p0[0]) * 180 / Math.PI; line.setAttribute("transform", \`translate(\${p0[0]}, \${p0[1]}) rotate(\${angle}, 0, 0) translate(0 \${-this._thickness/2})\`); line.setAttribute("x", 0); line.setAttribute("y", 0); line.setAttribute("rx", this._thickness/4); line.setAttribute("width", length); line.setAttribute("height", this._thickness); } /* let points = []; for(let segment of this.segments) points.push(\`\${segment[0]},\${segment[1]}\`); points = points.join(" "); this.line.setAttribute("points", points); */ for(let idx = 0; idx < this.segments.length; ++idx) { let segment = this.segments[idx]; let editPoint = this._editPoints[idx]; editPoint.setAttribute("cx", segment[0]); editPoint.setAttribute("cy", segment[1]); let radius = this._thickness / 2; editPoint.setAttribute("rx", radius); editPoint.setAttribute("ry", radius); } } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/viewer/images/editing-inpaint.js `), "/vview/viewer/images/editing-overlay-container.js": loadBlob("application/javascript", `/\x2f This is a custom element that roughly emulates an HTMLImageElement, but contains two /\x2f overlaid images instead of one to overlay the inpaint, and holds the InpaintEditorOverlay. /\x2f Load and error events are dispatched, and the image is considered loaded or complete when /\x2f both of its images are loaded or complete. This allows ViewerImages to display inpainting /\x2f and the inpaint editor without needing to know much about it, so we can avoid complicating /\x2f the viewer. import Widget from '/vview/widgets/widget.js'; import { helpers } from '/vview/misc/helpers.js'; export default class ImageEditingOverlayContainer extends Widget { constructor({ ...options }) { super({...options, template: \`
\`}); this._inpaintEditorOverlayContainer = this.root.querySelector(".inpaint-editor-overlay-container"); this._cropEditorOverlayContainer = this.root.querySelector(".crop-editor-overlay-container"); this._panEditorOverlayContainer = this.root.querySelector(".pan-editor-overlay-container"); } set inpaintEditorOverlay(node) { helpers.html.removeElements(this._inpaintEditorOverlayContainer); this._inpaintEditorOverlayContainer.appendChild(node); } set cropEditorOverlay(node) { helpers.html.removeElements(this._cropEditorOverlayContainer); this._cropEditorOverlayContainer.appendChild(node); } set panEditorOverlay(node) { helpers.html.removeElements(this._panEditorOverlayContainer); this._panEditorOverlayContainer.appendChild(node); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/viewer/images/editing-overlay-container.js `), "/vview/viewer/images/editing-pan.js": loadBlob("application/javascript", `/\x2f This allows editing simple pan animations, to give finer control over slideshows. import Widget from '/vview/widgets/widget.js'; import ImageEditingOverlayContainer from '/vview/viewer/images/editing-overlay-container.js'; import Slideshow from '/vview/misc/slideshow.js'; import PointerListener from '/vview/actors/pointer-listener.js'; import { helpers, FixedDOMRect, KeyListener } from '/vview/misc/helpers.js'; export default class PanEditor extends Widget { constructor(options) { super({...options, template: \`
\${ helpers.createBoxLink({popup: "Edit start", icon: "first_page", classes: ["edit-start-button"] }) } \${ helpers.createBoxLink({popup: "Swap start and end", icon: "swap_horiz", classes: ["swap-button"] }) } \${ helpers.createBoxLink({popup: "Edit end", icon: "last_page", classes: ["edit-end-button"] }) } \${ helpers.createBoxLink({popup: "Edit anchor", icon: "anchor", classes: ["edit-anchor"] }) } \${ helpers.createBoxLink({popup: "Portrait/landscape", icon: "panorama", classes: ["rotate-aspect-ratio"] }) } \${ helpers.createBoxLink({popup: "Clear animation", icon: "delete", classes: ["reset-button"] }) }
\`}); this.width = this.height = 100; this.dragging = false; this._dragStart = null; this.anchor = new FixedDOMRect(0.5, 0.5, 0.5, 0.5); this._aspectRatios = [ [21, 9], [16, 9], [16, 10], [4, 3], ]; /\x2f is_set is false if we've had no edits and we're displaying the defaults, or true if we /\x2f have data that can be saved. this._isSet = false; this._zoomLevel = [1,1]; /\x2f start, end this._displayedAspectRatio = 1; this._displayedAspectRatioPortrait = false; this.editing = "start"; /\x2f "start" or "end" this._editingAnchor = false; this.ui = this.root.querySelector(".editor-buttons"); this._monitorPreviewBox = this.root.querySelector(".monitor-preview-box"); /\x2f Remove .pan-editor-overlay. It's inserted into the image overlay when we /\x2f have one, so it pans and zooms with the image. this._editorOverlay = this.root.querySelector(".pan-editor-overlay"); this._editorCropRegion = this.root.querySelector(".pan-editor-crop-region"); this._editorOverlay.remove(); this._handle = this._editorOverlay.querySelector(".handle"); /\x2f The real zoom value is the amount the image will be zoomed onscreen: if it's set /\x2f to 2, the image is twice as big. The zoom slider is inverted: a slider value of /\x2f 1/2 gives a zoom of 2. This makes the zoom slider scale the size of the monitor /\x2f box linearly and feels more natural. this._zoomSlider = this.ui.querySelector(".zoom-slider"); /\x2f Use watchEdits to save undo at the start of inputs being dragged. helpers.watchEdits(this._zoomSlider, { signal: this.shutdownSignal }); this._zoomSlider.addEventListener("editbegin", (e) => { this.parent.saveUndo(); this._isSet = true; }); this._zoomSlider.addEventListener("edit", (e) => { /\x2f console.log(e); let value = parseInt(this._zoomSlider.value) / 100; value = 1 / value; this._zoomLevel[this.editingIndex] = value; this.refresh(); }); /\x2f The preview size slider changes the monitor aspect ratio that we're previewing. this._aspectRatioSlider = this.ui.querySelector(".aspect-ratio-slider input"); this._aspectRatioSlider.addEventListener("input", (e) => { this._displayedAspectRatio = parseInt(this._aspectRatioSlider.value); this.refresh(); }); this._aspectRatioSwitchButton = this.root.querySelector(".rotate-aspect-ratio"); this._aspectRatioSwitchButton.addEventListener("click", (e) => { e.stopPropagation(); this._displayedAspectRatioPortrait = !this._displayedAspectRatioPortrait; this.refresh(); }); this.ui.querySelector(".edit-start-button").addEventListener("click", (e) => { e.stopPropagation(); this.editing = "start"; this.refresh(); }); this.ui.querySelector(".edit-end-button").addEventListener("click", (e) => { e.stopPropagation(); this.editing = "end"; this.refresh(); }); this.ui.querySelector(".edit-anchor").addEventListener("click", (e) => { e.stopPropagation(); this._editingAnchor = !this._editingAnchor; this.refresh(); }); this.ui.querySelector(".reset-button").addEventListener("click", (e) => { e.stopPropagation(); this.clear(); }); this.ui.querySelector(".swap-button").addEventListener("click", (e) => { e.stopPropagation(); this.swap(); }); this.pointerListener = new PointerListener({ element: this._editorOverlay, callback: this.pointerevent, signal: this.shutdownSignal, }); /\x2f Prevent fullscreening if a UI element is double-clicked. this._editorOverlay.addEventListener("dblclick", this.ondblclick, { signal: this.shutdownSignal }); } /\x2f Return 0 if we're editing the start point, or 1 if we're editing the end point. get editingIndex() { return this.editing == "start"? 0:1; } get actuallyEditingAnchor() { return this._editingAnchor ^ this._shiftHeld; } /\x2f This is called when the ImageEditingOverlayContainer changes. set overlayContainer(overlayContainer) { console.assert(overlayContainer instanceof ImageEditingOverlayContainer); if(this._editorOverlay.parentNode) this._editorOverlay.remove(); overlayContainer.panEditorOverlay = this._editorOverlay; this._overlayContainer = overlayContainer; } clear() { if(!this._isSet) return; this.parent.saveUndo(); this.setState(null); } /\x2f Swap the start and end points. swap() { this.parent.saveUndo(); this._isSet = true; this.rect = new FixedDOMRect(this.rect.x2, this.rect.y2, this.rect.x1,this.rect.y1); this.anchor = new FixedDOMRect(this.anchor.x2, this.anchor.y2, this.anchor.x1, this.anchor.y1); this._zoomLevel = [this._zoomLevel[1], this._zoomLevel[0]]; this.refresh(); } get previewSize() { let result = this._aspectRatios[this._displayedAspectRatio]; if(this._displayedAspectRatioPortrait) return [result[1], result[0]]; else return result; } refresh() { super.refresh(); if(!this.visible) return; let zoom = this._zoomLevel[this.editingIndex]; this._zoomSlider.value = 1 / zoom * 100; helpers.html.setClass(this.ui.querySelector(".edit-start-button"), "selected", this.editing == "start"); helpers.html.setClass(this.ui.querySelector(".edit-end-button"), "selected", this.editing == "end"); helpers.html.setClass(this.ui.querySelector(".edit-anchor"), "selected", this.actuallyEditingAnchor); this._aspectRatioSwitchButton.dataset.popup = this._displayedAspectRatioPortrait? "Previewing portrait":"Previewing landscape"; this._aspectRatioSwitchButton.querySelector(".font-icon").innerText = this._displayedAspectRatioPortrait? "portrait":"panorama"; this._aspectRatioSlider.value = this._displayedAspectRatio; this.ui.querySelector(".aspect-ratio-slider").dataset.popup = \`Previewing \${this.previewSize[0]}:\${this.previewSize[1]}\`; this.refreshZoomPreview(); this.refreshCenter(); } /\x2f Refresh the position of the center handle. refreshCenter() { let { x, y } = this.editing == "start"? { x: this.rect.x1, y: this.rect.y1 }: { x: this.rect.x2, y: this.rect.y2 }; x *= this.width; y *= this.height; this._handle.querySelector(".crosshair").setAttribute("transform", \`translate(\${x} \${y})\`); } visibilityChanged() { super.visibilityChanged(); this._editorOverlay.hidden = !this.visible; this.ui.hidden = !this.visible; if(this.visible) { /\x2f Listen for shift presses while we're visible. new KeyListener("Shift", (pressed) => { this._shiftHeld = pressed; this.refresh(); }, { signal: this.visibilityAbort.signal }); this.refresh(); } else { this._shiftHeld = false; } } setIllustData({replaceEditorData, extraData, width, height}) { /\x2f Match the size of the image. this.width = width; this.height = height; /\x2f Handling crops and pans together is tricky. The pan values are relative to the cropped /\x2f area: panning to 0.5x0.5 always goes to the center of the crop region, not the original /\x2f image. But, these editors are all positioned and scaled relative to the original image. /\x2f This editor wants to be relative to the crop, so we scale and shift our own area relative /\x2f to the crop if there is one. if(extraData?.crop) { let crop = new FixedDOMRect(extraData.crop[0], extraData.crop[1], extraData.crop[2], extraData.crop[3]); this.width = crop.width; this.height = crop.height; this._editorCropRegion.style.width = \`\${100 * crop.width / width}%\`; this._editorCropRegion.style.height = \`\${100 * crop.height / height}%\`; this._editorCropRegion.style.top = \`\${100 * crop.top / height}%\`; this._editorCropRegion.style.left = \`\${100 * crop.left / width}%\`; } else { this._editorCropRegion.style.width = this._editorCropRegion.style.height = \`\`; this._editorCropRegion.style.top = this._editorCropRegion.style.left = \`\`; } this._handle.setAttribute("viewBox", \`0 0 \${this.width} \${this.height}\`); if(replaceEditorData) this.setState(extraData?.pan); this.refresh(); } getDataToSave() { return { pan: this.getState() }; } /\x2f Return data for saving. getState({force=false}={}) { if(!force && !this._isSet) return null; /\x2f These are stored as unit values, so we don't need to know the image dimensions to /\x2f set them up. let result = { x1: this.rect.x1, y1: this.rect.y1, x2: this.rect.x2, y2: this.rect.y2, start_zoom: this._zoomLevel[0], end_zoom: this._zoomLevel[1], }; /\x2f Only include the anchor if it's been changed from the default. if(Math.abs(this.anchor.x1 - 0.5) > 0.001 || Math.abs(this.anchor.y1 - 0.5) > 0.001 || Math.abs(this.anchor.x2 - 0.5) > 0.001 || Math.abs(this.anchor.y2 - 0.5) > 0.001) { result.anchor = { left: this.anchor.x1, top: this.anchor.y1, right: this.anchor.x2, bottom: this.anchor.y2, }; } return result; } setState(data) { this._isSet = data != null; if(data == null) data = Slideshow.pans.defaultSlideshow; this.rect = new FixedDOMRect(data.x1, data.y1, data.x2, data.y2); this.anchor = new FixedDOMRect(0.5, 0.5, 0.5, 0.5); if(data.anchor) this.anchor = new FixedDOMRect(data.anchor.left, data.anchor.top, data.anchor.right, data.anchor.bottom); this._zoomLevel = [data.start_zoom, data.end_zoom]; this.refresh(); } getCurrentSlideshow({...options}={}) { /\x2f this.height/this.width is the size of the image. Scale it to cover previewWidth/previewHeight, /\x2f as if we're ViewerImages displaying it. If the animation tells us to scale to 1x, it wants /\x2f to cover the screen. let [previewWidth, previewHeight] = this.previewSize; let scaleRatio = Math.max(previewWidth/this.width, previewHeight/this.height); let scaledWidth = this.width * scaleRatio, scaledHeight = this.height * scaleRatio; /\x2f The minimum zoom is the zoom that will fit the image onscreen. This also matches ViewerImages. let coverRatio = Math.min(previewWidth/scaledWidth, previewHeight/scaledHeight); let slideshow = new Slideshow({ width: scaledWidth, height: scaledHeight, containerWidth: previewWidth, containerHeight: previewHeight, /\x2f The minimum zoom level to allow: minimumZoom: coverRatio, /\x2f The position is normally clamped to the screen. If we're editing the anchor, disable this to /\x2f display the position of the box before it's clamped. clampToWindow: !this.actuallyEditingAnchor, ...options }); /\x2f Get the animation that we'd currently save, and load it as a slideshow. let panAnimation = this.getState({force: true}); let animation = slideshow.getAnimation(panAnimation); return { animation, scaledWidth, scaledHeight, previewWidth, previewHeight }; } /\x2f Refresh the position and size of the monitor preview box. refreshZoomPreview() { /\x2f Instead of moving the image around inside the monitor, scale the box to the size /\x2f of the preview "monitor", and scale/translate it around to show how the image would /\x2f fit inside it. let { animation, scaledWidth, scaledHeight, previewWidth, previewHeight } = this.getCurrentSlideshow(); let pan = animation.pan[this.editingIndex]; let box = this._monitorPreviewBox.querySelector(".box"); box.style.width = \`\${100 * previewWidth / scaledWidth}%\`; box.style.height = \`\${100 * previewHeight / scaledHeight}%\`; let tx = 100 * -pan.tx / scaledWidth; let ty = 100 * -pan.ty / scaledHeight; /\x2f Apply the zoom by scaling the box's parent. Scaling inside style.transform makes this simpler, /\x2f but makes things like outlines ugly. this._monitorPreviewBox.style.width = \`\${100 / pan.scale}%\`; this._monitorPreviewBox.style.height = \`\${100 / pan.scale}%\`; this._monitorPreviewBox.style.transform = \` translateX(\${tx}%) translateY(\${ty}%) \`; } pointerevent = (e) => { if(e.pressed) { e.preventDefault(); e.stopPropagation(); this.dragging = true; this._dragSavedUndo = false; this._dragPos = [e.clientX, e.clientY]; window.addEventListener("pointermove", this._pointermoveDragPoint); return; } else if(this.dragging != -1 && !e.pressed) { /\x2f We stopped dragging. this.dragging = false; window.removeEventListener("pointermove", this._pointermoveDragPoint); } } /\x2f Convert a click from client coordinates to image coordinates. getPointFromClick({clientX, clientY}) { let {width, height, top, left} = this._editorOverlay.getBoundingClientRect(); let x = (clientX - left) / width * this.width; let y = (clientY - top) / height * this.height; return { x: x, y: y }; } _pointermoveDragPoint = (e) => { /\x2f Save undo for this drag if we haven't yet. if(!this._dragSavedUndo) { this.parent.saveUndo(); this._dragSavedUndo = true; } /\x2f Get the delta in client coordinates. Don't use movementX/movementY, since it's /\x2f in screen pixels and will be wrong if the browser is scaled. let deltaX = e.clientX - this._dragPos[0]; let deltaY = e.clientY - this._dragPos[1]; this._dragPos = [e.clientX, e.clientY]; /\x2f Scale movement from client coordinates to the size of the container. let {width, height} = this._editorCropRegion.getBoundingClientRect(); deltaX /= width; deltaY /= height; /\x2f Check if we're editing the pan position or the anchor. let editingAnchor = this.actuallyEditingAnchor; if(editingAnchor) { let { animation, scaledWidth, scaledHeight, previewWidth, previewHeight } = this.getCurrentSlideshow(); let pan = animation.pan[this.editingIndex]; /\x2f If we add 1 to anchor.x1, we'll move the anchor one screen width to the right. /\x2f Scale this to the monitor preview that's currently visible. This makes the speed /\x2f of dragging the anchor point match the current display. /\x2f /\x2f Moving the anchor will also move the view, so we also adjust the view position by /\x2f the same amount below. This cancels out the movement of the anchor, so the display /\x2f position is stationary as we move the anchor. let monitorWidth = (previewWidth / scaledWidth) / pan.scale; let monitorHeight = (previewHeight / scaledHeight) / pan.scale; if(this.editing == "start") { this.anchor.x1 += deltaX / monitorWidth; this.anchor.y1 += deltaY / monitorHeight; } else { this.anchor.x2 += deltaX / monitorWidth; this.anchor.y2 += deltaY / monitorHeight; } } /\x2f Drag the rect. let rect = new FixedDOMRect(this.rect.x1, this.rect.y1, this.rect.x2, this.rect.y2); if(this.editing == "start") { rect.x1 += deltaX; rect.y1 += deltaY; } else { rect.x2 += deltaX; rect.y2 += deltaY; } this.rect = rect; this._isSet = true; this.refresh(); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/viewer/images/editing-pan.js `), "/vview/viewer/images/editing.js": loadBlob("application/javascript", `import PanEditor from '/vview/viewer/images/editing-pan.js'; import InpaintEditor from '/vview/viewer/images/editing-inpaint.js'; import CropEditor from '/vview/viewer/images/editing-crop.js'; import LocalAPI from '/vview/misc/local-api.js'; import { HideMouseCursorOnIdle } from '/vview/misc/hide-mouse-cursor-on-idle.js'; import { IllustWidget } from '/vview/widgets/illust-widgets.js'; import { helpers, OpenWidgets } from '/vview/misc/helpers.js'; export default class ImageEditor extends IllustWidget { constructor({ /\x2f The ImageEditingOverlayContainer, which holds editor UI that goes inside the /\x2f image box. overlayContainer, onvisibilitychanged, visible=null, ...options }) { /\x2f Set our default visibility to the image_editing setting. if(visible == null) visible = ppixiv.settings.get("image_editing", false); super({...options, visible, template: \`
\${ helpers.createBoxLink({icon: "undo", popup: "Undo", classes: ["undo", "popup-bottom"] }) } \${ helpers.createBoxLink({icon: "redo", popup: "Redo", classes: ["redo", "popup-bottom"] }) }
\${ helpers.createBoxLink({icon: "save", popup: "Save", classes: ["save-edits", "popup-bottom"] }) } \${ helpers.createBoxLink({icon: "refresh", popup: "Saving...", classes: ["spinner"] }) } \${ helpers.createBoxLink({icon: "crop", popup: "Crop", classes: ["show-crop", "popup-bottom"] }) } \${ helpers.createBoxLink({icon: "wallpaper",popup: "Edit panning", classes: ["show-pan", "popup-bottom"] }) } \${ helpers.createBoxLink({icon: "brush", popup: "Inpainting", classes: ["show-inpaint", "popup-bottom"], dataset: { popupSide: "center" } }) }
\${ helpers.createBoxLink({icon: "close", popup: "Stop editing", classes: ["close-editor", "popup-bottom"], dataset: { popupSide: "left" } }) }
\`}); this.root.querySelector(".spinner").hidden = true; let cropEditor = new CropEditor({ container: this.root, mode: "crop", visible: false, }); let panEditor = new PanEditor({ container: this.root, visible: false, }); let inpaintEditor = new InpaintEditor({ container: this.root, visible: false, }); this.editors = { inpaint: inpaintEditor, crop: cropEditor, pan: panEditor, }; this.onvisibilitychanged = onvisibilitychanged; this._dirty = false; this._editingMediaId = null; this._undoStack = []; this._redoStack = []; this._topButtonRow = this.root.querySelector(".image-editor-buttons.top"); this._showCrop = this.root.querySelector(".show-crop"); this._showCrop.addEventListener("click", (e) => { e.stopPropagation(); this.activeEditorName = this.activeEditorName == "crop"? null:"crop"; }); this._showPan = this.root.querySelector(".show-pan"); this._showPan.addEventListener("click", (e) => { e.stopPropagation(); this.activeEditorName = this.activeEditorName == "pan"? null:"pan"; }); this._showInpaint = this.root.querySelector(".show-inpaint"); this._showInpaint.hidden = true; this._showInpaint.addEventListener("click", (e) => { e.stopPropagation(); this.activeEditorName = this.activeEditorName == "inpaint"? null:"inpaint"; }); this.overlayContainer = overlayContainer; OpenWidgets.singleton.addEventListener("changed", this._refreshTemporarilyHidden, { signal: this.shutdownSignal }); window.addEventListener("keydown", (e) => { if(!this.visible) return; if(e.code == "KeyC" && e.ctrlKey) { /\x2f It's tricky to figure out if there's something the user might be trying to copy. /\x2f See if there's a text selection. This requires that anything that might have /\x2f a selection disable selection with user-select: none while it's hidden, so the /\x2f selection doesn't stick around while it's not visible, but that's generally /\x2f a good idea anyway. if(getSelection().toString() != "") { console.log("Not copying editor because text is selected"); return; } e.preventDefault(); e.stopPropagation(); this.copy(); } else if(e.code == "KeyV" && e.ctrlKey) { e.preventDefault(); e.stopPropagation(); this.paste(); } }, { signal: this.shutdownSignal }); /\x2f Refresh when these settings change. for(let setting of ["image_editing", "image_editing_mode"]) ppixiv.settings.addEventListener(setting, () => { this.refresh(); /\x2f Let our parent know that we may have changed editor visibility, since this /\x2f affects whether image cropping is active. this.onvisibilitychanged(); }, { signal: this.shutdownSignal }); /\x2f Stop propagation of pointerdown at the container, so clicks inside the UI don't /\x2f move the image. this.root.addEventListener("pointerdown", (e) => { e.stopPropagation(); }); /\x2f Prevent fullscreen doubleclicks on UI buttons. this.root.addEventListener("dblclick", (e) => { e.stopPropagation(); }); this._saveEdits = this.root.querySelector(".save-edits"); this._saveEdits.addEventListener("click", async (e) => { e.stopPropagation(); this.save(); }, { signal: this.shutdownSignal }); this._closeEditor = this.root.querySelector(".close-editor"); this._closeEditor.addEventListener("click", async (e) => { e.stopPropagation(); ppixiv.settings.set("image_editing", null); ppixiv.settings.set("image_editing_mode", null); }, { signal: this.shutdownSignal }); this._undoButton = this.root.querySelector(".undo"); this._redoButton = this.root.querySelector(".redo"); this._undoButton.addEventListener("click", async (e) => { e.stopPropagation(); this.undo(); }, { signal: this.shutdownSignal }); this._redoButton.addEventListener("click", async (e) => { e.stopPropagation(); this.redo(); }, { signal: this.shutdownSignal }); /\x2f Hotkeys: window.addEventListener("keydown", (e) => { if(e.code == "KeyS" && e.ctrlKey) { e.stopPropagation(); e.preventDefault(); this.save(); } if(e.code == "KeyZ" && e.ctrlKey) { e.stopPropagation(); e.preventDefault(); this.undo(); } if(e.code == "KeyY" && e.ctrlKey) { e.stopPropagation(); e.preventDefault(); this.redo(); } }, { signal: this.shutdownSignal }); } /\x2f Return true if the crop editor is active. get editingCrop() { return ppixiv.settings.get("image_editing", false) && this.activeEditorName == "crop"; } _refreshTemporarilyHidden = () => { /\x2f Hide while the UI is open. This is only needed on mobile, where our buttons /\x2f overlap the hover UI. let hidden = ppixiv.mobile && !OpenWidgets.singleton.empty; helpers.html.setClass(this.root, "temporarily-hidden", hidden); } visibilityChanged() { if(ppixiv.settings.get("image_editing") != this.visible) ppixiv.settings.set("image_editing", this.visible); /\x2f Refresh to update editor visibility. this.refresh(); this.onvisibilitychanged(); super.visibilityChanged(); } /\x2f In principle we could refresh from thumbnail data if this is the first manga page, since /\x2f all we need is the image dimensions. However, the editing container is only displayed /\x2f by ViewerImages after we have full image data anyway since it's treated as part of the /\x2f main image, so we won't be displayed until then anyway. async refreshInternal({ mediaId, mediaInfo }) { /\x2f We can get the media ID before we have mediaInfo. Ignore it until we have both. if(mediaInfo == null) mediaId = null; let editorIsOpen = this.openEditor != null; let mediaIdChanging = mediaId != this._editingMediaId; this._editingMediaId = mediaId; /\x2f Only tell the editor to replace its own data if we're changing images, or the /\x2f editor is closed. If the editor is open and we're not changing images, don't /\x2f clobber ongoing edits. let replaceEditorData = mediaIdChanging || !editorIsOpen; /\x2f For local images, editing data is simply stored as a field on the illust data, which /\x2f we can save to the server. /\x2f /\x2f For Pixiv images, we store editing data locally in IndexedDB. All pages are stored on /\x2f the data for the first page, as an extraData dictionary with page media IDs as keys. /\x2f /\x2f Pull out the dictionary containing editing data for this image to give to the editor. let { width, height } = ppixiv.mediaCache.getImageDimensions(mediaInfo, mediaId); let extraData = ppixiv.mediaCache.getExtraData(mediaInfo, mediaId); /\x2f Give the editors the new illust data. for(let editor of Object.values(this.editors)) editor.setIllustData({ mediaId, extraData, width, height, replaceEditorData }); /\x2f If no editor is open, make sure the undo stack is cleared and clear dirty. if(!editorIsOpen) { /\x2f Otherwise, just make sure the undo stack is cleared. this._undoStack = []; this._redoStack = []; this.dirty = false; } this._refreshTemporarilyHidden(); } get openEditor() { for(let editor of Object.values(this.editors)) { if(editor.visible) return editor; } return null; } /\x2f This is called when the ImageEditingOverlayContainer changes. set overlayContainer(overlayContainer) { this.currentOverlayContainer = overlayContainer; for(let editor of Object.values(this.editors)) editor.overlayContainer = overlayContainer; } refresh() { super.refresh(); this.visible = ppixiv.settings.get("image_editing", false); helpers.html.setClass(this._saveEdits, "dirty", this.dirty); let isLocal = helpers.mediaId.isLocal(this._mediaId); if(this._mediaId != null) this._showInpaint.hidden = !isLocal; let showingCrop = this.activeEditorName == "crop" && this.visible; this.editors.crop.visible = showingCrop; helpers.html.setClass(this._showCrop, "selected", showingCrop); let showingPan = this.activeEditorName == "pan" && this.visible; this.editors.pan.visible = showingPan; helpers.html.setClass(this._showPan, "selected", showingPan); let showingInpaint = isLocal && this.activeEditorName == "inpaint" && this.visible; this.editors.inpaint.visible = showingInpaint; helpers.html.setClass(this._showInpaint, "selected", showingInpaint); helpers.html.setClass(this._undoButton, "disabled", this._undoStack.length == 0); helpers.html.setClass(this._redoButton, "disabled", this._redoStack.length == 0); /\x2f Hide the undo buttons in the top-left when no editor is active, since it overlaps the hover /\x2f UI. Undo doesn't handle changes across editors well currently anyway. this._topButtonRow.querySelector(".left").hidden = this.activeEditorName == null; /\x2f Disable hiding the mouse cursor when editing is enabled. This also prevents /\x2f the top button row from being hidden. if(showingCrop || showingInpaint) HideMouseCursorOnIdle.disableAll("image-editing"); else HideMouseCursorOnIdle.enableAll("image-editing"); } /\x2f Store the current data as an undo state. saveUndo() { this._undoStack.push(this.getState()); this._redoStack = []; /\x2f Anything that adds to the undo stack causes us to be dirty. this.dirty = true; } /\x2f Revert to the previous undo state, if any. undo() { if(this._undoStack.length == 0) return; this._redoStack.push(this.getState()); this.setState(this._undoStack.pop()); /\x2f If InpaintEditor was adding a line, we just undid the first point, so end it. this.editors.inpaint.addingline = null; this.refresh(); } /\x2f Redo the last undo. redo() { if(this._redoStack.length == 0) return; this._undoStack.push(this.getState()); this.setState(this._redoStack.pop()); this.refresh(); } /\x2f Load and save state, for undo. getState() { let result = {}; for(let [name, editor] of Object.entries(this.editors)) result[name] = editor.getState(); return result; } setState(state) { for(let [name, editor] of Object.entries(this.editors)) editor.setState(state[name]); } getDataToSave({includeEmpty=true}={}) { let edits = { }; for(let editor of Object.values(this.editors)) { for(let [key, value] of Object.entries(editor.getDataToSave())) { if(includeEmpty || value != null) edits[key] = value; } } return edits; } async save() { /\x2f Clear dirty before saving, so any edits made while saving will re-dirty, but set /\x2f it back to true if there's an error saving. this.dirty = false; let spinner = this.root.querySelector(".spinner"); this._saveEdits.hidden = true; spinner.hidden = false; try { /\x2f Get data from each editor. let edits = this.getDataToSave(); if(helpers.mediaId.isLocal(this._mediaId)) { let result = await LocalAPI.localPostRequest(\`/api/set-image-edits/\${this._mediaId}\`, edits); if(!result.success) { ppixiv.message.show(\`Error saving image edits: \${result.reason}\`); console.error("Error saving image edits:", result); this.dirty = true; return; } /\x2f Update cached media info to include the change. ppixiv.mediaCache.addFullMediaInfo(result.illust); } else { /\x2f Save data for Pixiv images to image_data. await ppixiv.mediaCache.saveExtraImageData(this._mediaId, edits); } /\x2f Let the widgets know that we saved. let currentEditor = this.activeEditor; let mediaInfo = ppixiv.mediaCache.getMediaInfoSync(this._mediaId); if(currentEditor?.afterSave) currentEditor.afterSave(mediaInfo); } finally { this._saveEdits.hidden = false; spinner.hidden = true; } } async copy() { let data = this.getDataToSave({includeEmpty: false}); if(Object.keys(data).length == 0) { ppixiv.message.show("No edits to copy"); return; } data.type = "ppixiv-edits"; data = JSON.stringify(data, null, 4); /\x2f We should be able to write to the clipboard with a custom MIME type that we can /\x2f recognize, but the clipboard API is badly designed and only lets you write a tiny /\x2f set of types. await navigator.clipboard.write([ new ClipboardItem({ "text/plain": new Blob([data], { type: "text/plain" }) }) ]); ppixiv.message.show("Edits copied"); } async paste() { let text = await navigator.clipboard.readText(); let data; try { data = JSON.parse(text); } catch(e) { ppixiv.message.show("Clipboard doesn't contain edits"); return; } if(data.type != "ppixiv-edits") { ppixiv.message.show("Clipboard doesn't contain edits"); return; } this.setState(data); await this.save(); ppixiv.message.show("Edits pasted"); } get activeEditorName() { return ppixiv.settings.get("image_editing_mode", null); } set activeEditorName(editorName) { if(editorName != null && this.editors[editorName] == null) throw new Error(\`Invalid editor name \${editorName}\`); ppixiv.settings.set("image_editing_mode", editorName); } get activeEditor() { let currentEditor = this.activeEditorName; if(currentEditor == null) return null; else return this.editors[currentEditor]; } get dirty() { return this._dirty; } set dirty(value) { if(this._dirty == value) return; this._dirty = value; this.refresh(); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/viewer/images/editing.js `), "/vview/viewer/images/mobile-touch-scroller.js": loadBlob("application/javascript", `/\x2f Mobile panning, fling and pinch zooming. import DragHandler from '/vview/misc/drag-handler.js'; import FlingVelocity from '/vview/util/fling-velocity.js'; import Actor from '/vview/actors/actor.js'; import { helpers } from '/vview/misc/helpers.js'; const FlingFriction = 7; const FlingMinimumVelocity = 10; export default class TouchScroller extends Actor { constructor({ /\x2f The container to watch for pointer events on: container, /\x2f setPosition({x, y}) setPosition, /\x2f { x, y } = getPosition() getPosition, /\x2f Zoom in or out by ratio, centered around the given position. adjustZoom, /\x2f Return a FixedDOMRect for the bounds of the image. The position we set can overscroll /\x2f out of this rect, but we'll bounce back in. This can change over time, such as due to /\x2f the zoom level changing. getBounds, /\x2f If the current zoom is outside the range the viewer wants, return the ratio from the /\x2f current zoom to the wanted zoom. This is applied along with rubber banding. getWantedZoom, /\x2f Callbacks: onactive = () => { }, oninactive = () => { }, ondragstart = () => { }, ondragend = () => { }, onanimationstart = () => { }, onanimationfinished = () => { }, ...options }) { super(options); this.root = container; this.options = { getPosition, setPosition, getBounds, getWantedZoom, adjustZoom, onactive, oninactive, ondragstart, ondragend, onanimationstart, onanimationfinished, }; this.velocity = {x: 0, y: 0}; this._flingVelocity = new FlingVelocity(); /\x2f This is null if we're inactive, "dragging" if the user is dragging, or "animating" if we're /\x2f flinging and rebounding. this._state = "idle"; /\x2f Cancel any running fling if we're shut down while a fling is active. this.shutdownSignal.addEventListener("abort", (e) => this.cancelFling(), { once: true }); this.dragger = new DragHandler({ parent: this, name: "TouchScroller", element: container, pinch: true, deferDelayMs: 30, confirmDrag: ({event}) => !helpers.shouldIgnoreHorizontalDrag(event), ondragstart: (...args) => this._ondragstart(...args), ondrag: (...args) => this._ondrag(...args), ondragend: (...args) => this._ondragend(...args), }); } get state() { return this._state; } /\x2f Cancel any drag immediately without starting a fling. cancelDrag() { if(this._state != "dragging") return; this.dragger.cancelDrag(); this._setState("idle"); } /\x2f Set the current state: "idle", "dragging" or "animating", running the /\x2f appropriate callbacks. _setState(state, args={}) { if(state == this._state) return; /\x2f Transition back to active, ending whichever state we were in before. if(state != "idle" && this._changeState("idle", "active")) this.options.onactive(args); if(state != "dragging" && this._changeState("dragging", "active")) this.options.ondragend(args); if(state != "animating" && this._changeState("animating", "active")) this.options.onanimationfinished(args); /\x2f Transition into the new state, beginning the new state. if(state == "dragging" && this._changeState("active", "dragging")) this.options.ondragstart(args); if(state == "animating" && this._changeState("active", "animating")) this.options.onanimationstart(args); if(state == "idle" && this._changeState("active", "idle")) this.options.oninactive(args); } _changeState(oldState, newState) { if(this._state != oldState) return false; /\x2f console.warn(\`state change: \${oldState} -> \${newState}\`); this._state = newState; /\x2f Don't call onstatechange for active, since it's just a transition between /\x2f other states. /\x2f if(newState != "active") /\x2f this.onstatechange(); return true; } _ondragstart() { /\x2f If we were flinging, the user grabbed the fling and interrupted it. if(this._state == "animating") this.cancelFling(); this._setState("dragging"); /\x2f Kill any velocity when a drag starts. this._flingVelocity.reset(); /\x2f If the image fits onscreen on one or the other axis, don't allow panning on /\x2f that axis. This is the same as how our mouse panning works. However, only /\x2f enable this at the start of a drag: if axes are unlocked at the start, don't /\x2f lock them as a result of pinch zooming. Otherwise we'll start locking axes /\x2f in the middle of dragging due to zooms. let { width, height } = this.options.getBounds(); this.dragAxesLocked = [width < 0.001, height < 0.001]; return true; } _ondrag({ first, movementX, movementY, x, y, radius, previousRadius, }) { if(this._state != "dragging") return; /\x2f Ignore the first pointer movement. if(first) return; /\x2f We're overscrolling if we're out of bounds on either axis, so apply drag to /\x2f the pan. let position = this.options.getPosition(); let bounds = this.options.getBounds(); let overscrollX = Math.max(bounds.left - position.x, position.x - bounds.right); let overscrollY = Math.max(bounds.top - position.y, position.y - bounds.bottom); if(overscrollX > 0) movementX *= Math.pow(this.overscrollStrength, overscrollX); if(overscrollY > 0) movementY *= Math.pow(this.overscrollStrength, overscrollY); /\x2f If movement is locked on either axis, zero it. if(this.dragAxesLocked[0]) movementX = 0; if(this.dragAxesLocked[1]) movementY = 0; /\x2f Apply the pan. this.options.setPosition({ x: position.x - movementX, y: position.y - movementY}); /\x2f Store this motion sample, so we can estimate fling velocity later. This should be /\x2f affected by axis locking above. this._flingVelocity.addSample({ x: -movementX, y: -movementY }); /\x2f If we zoomed in and now have room to move on an axis that was locked before, /\x2f unlock it. We won't lock it again until a new drag is started. if(bounds.width >= 0.001) this.dragAxesLocked[0] = false; if(bounds.height >= 0.001) this.dragAxesLocked[1] = false; /\x2f The zoom for this frame is the ratio of the change of the average distance from the /\x2f anchor, centered around the average touch position. if(previousRadius > 0) { let ratio = radius / previousRadius; this.options.adjustZoom({ratio, centerX: x, centerY: y}); } } _ondragend(e) { /\x2f The last touch was released. If we were dragging, start flinging or rubber banding. if(this._state == "dragging") this.startFling(); } get overscrollStrength() { return 0.994; } /\x2f Switch from dragging to flinging. /\x2f /\x2f This can be called by the user to force a fling to begin, allowing this to be used /\x2f for smooth bouncing. onanimationstartOptions will be passed to onanimationstart /\x2f for convenience. startFling({onanimationstartOptions={}}={}) { /\x2f We shouldn't already be flinging when this is called. if(this._state == "animating") { console.warn("Already animating"); return; } /\x2f Don't start a fling if a drag is active. this._state can be "dragging" if the drag /\x2f just ended and we're transitioning into "animating", but don't do this if we're called /\x2f while a drag is still active. This happens the user double-clicks to zoom the image /\x2f while still dragging; if(this.dragger.isDragging) { /\x2f console.log("Ignoring startFling because a drag is still active"); return; } /\x2f Set the initial velocity to the average recent speed of all touches. this.velocity = this._flingVelocity.currentVelocity; this._setState("animating", onanimationstartOptions); console.assert(this._abortFling == null); this._abortFling = new AbortController(); this._runFling(this._abortFling.signal); } /\x2f Handle a fling asynchronously. Stop when the fling ends or signal is aborted. async _runFling(signal) { let previousTime = Date.now() / 1000; while(this._state == "animating") { let success = await helpers.other.vsync({ signal }); if(!success) return; let newTime = Date.now() / 1000; let duration = newTime - previousTime; previousTime = newTime; let movementX = this.velocity.x * duration; let movementY = this.velocity.y * duration; /\x2f Apply the velocity to the current position. let currentPosition = this.options.getPosition(); currentPosition.x += movementX; currentPosition.y += movementY; /\x2f Decay our velocity. let decay = Math.exp(-FlingFriction * duration); this.velocity.x *= decay; this.velocity.y *= decay; /\x2f If we're out of bounds, accelerate towards being in-bounds. This simply moves us /\x2f towards being in-bounds based on how far we are from it, which gives the effect /\x2f of acceleration. let bounced = this.applyPositionBounce(duration, currentPosition); if(this._applyZoomBounce(duration)) bounced = true; /\x2f Stop if our velocity has decayed and we're not rebounding. let totalVelocity = Math.pow(Math.pow(this.velocity.x, 2) + Math.pow(this.velocity.y, 2), 0.5); if(!bounced && totalVelocity < FlingMinimumVelocity) break; } /\x2f We've reached (near) zero velocity. Clamp the velocity to 0. this.velocity = { x: 0, y: 0 }; this._abortFling = null; this._setState("idle"); } _applyZoomBounce(duration) { /\x2f See if we want to bounce the zoom. This is used to scale the viewer back up to /\x2f 1x if the image is zoomed lower than that. let { ratio, centerX, centerY } = this.options.getWantedZoom(); if(Math.abs(1-ratio) < 0.001) return false; /\x2f While we're figuring out the speed, invert ratios less than 1 (zooming down) so /\x2f the ratios are linear. let inverted = ratio < 1; if(inverted) ratio = 1/ratio; /\x2f The speed we'll actually apply the zoom ratio. If this is 2, we'll adjust the ratio /\x2f by 2x per second (or .5x when zooming down). Scale this based on how far we have to /\x2f zoom, so zoom bounce decelerates similarly to position bounce. Clamp the ratio we'll /\x2f apply based on the duration of this frame. let zoomRatioPerSecond = Math.pow(ratio, 10); let maxRatioThisFrame = Math.pow(zoomRatioPerSecond, duration); ratio = Math.min(ratio, maxRatioThisFrame); if(inverted) ratio = 1/ratio; /\x2f Zoom centered on the position bounds, which is normally the center of the image. this.options.adjustZoom({ratio, centerX, centerY}); return true; } /\x2f If we're out of bounds, push the position towards being in bounds. Return true if /\x2f we were out of bounds. applyPositionBounce(duration, position) { let bounds = this.options.getBounds(); let factor = 0.025; /\x2f Bounce right: if(position.x < bounds.left) { let bounceVelocity = bounds.left - position.x; bounceVelocity *= factor; position.x += bounceVelocity * duration * 300; if(position.x >= bounds.left - 1) position.x = bounds.left; } /\x2f Bounce left: if(position.x > bounds.right) { let bounceVelocity = bounds.right - position.x; bounceVelocity *= factor; position.x += bounceVelocity * duration * 300; if(position.x <= bounds.right + 1) position.x = bounds.right; } /\x2f Bounce down: if(position.y < bounds.top) { let bounceVelocity = bounds.top - position.y; bounceVelocity *= factor; position.y += bounceVelocity * duration * 300; if(position.y >= bounds.top - 1) position.y = bounds.top; } /\x2f Bounce up: if(position.y > bounds.bottom) { let bounceVelocity = bounds.bottom - position.y; bounceVelocity *= factor; position.y += bounceVelocity * duration * 300; if(position.y <= bounds.bottom + 1) position.y = bounds.bottom; } this.options.setPosition(position); /\x2f Return true if we're still out of bounds. return position.x < bounds.left || position.y < bounds.top || position.x > bounds.right || position.y > bounds.bottom; } cancelFling() { if(this._state != "animating") return; if(this._abortFling) { this._abortFling.abort(); this._abortFling = null; } this._setState("idle"); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/viewer/images/mobile-touch-scroller.js `), "/vview/viewer/images/mobile-viewer-images.js": loadBlob("application/javascript", `import ViewerImages from '/vview/viewer/images/viewer-images.js'; import TouchScroller from '/vview/viewer/images/mobile-touch-scroller.js'; import { FixedDOMRect } from '/vview/misc/helpers.js'; /\x2f This subclass implements our touchscreen pan/zoom UI. export default class ViewerImagesMobile extends ViewerImages { constructor({...options}) { super(options); this.root.addEventListener("pointerdown", (e) => { if(this._slideshowMode || !this._animationsRunning) return; /\x2f Taps during panning animations stop the animation. Mark them as partially /\x2f handled, so they don't also trigger IsolatedTapHandler and open the menu. /\x2f Do this here instead of in onactive below, so this happens even if the touch /\x2f isn't long enough to activate TouchScroller. e.partiallyHandled = true; }); this._touchScroller = new TouchScroller({ parent: this, container: this.root, onactive: () => { /\x2f Stop pan animations if the touch scroller becomes active. if(!this._slideshowMode) this._stopAnimation(); }, /\x2f Return the current position in client coordinates. getPosition: () => { /\x2f We're about to start touch dragging, so stop any running pan. Don't stop slideshows. if(!this._slideshowMode) this._stopAnimation(); let x = this._centerPos[0] * this.currentWidth; let y = this._centerPos[1] * this.currentHeight; /\x2f Convert from view coordinates to screen coordinates. [x,y] = this.viewToClientCoords([x,y]); return { x, y }; }, /\x2f Set the current position in client coordinates. setPosition: ({x, y}) => { if(this._slideshowMode) return; this._stopAnimation(); [x,y] = this.clientToViewCoords([x,y]); x /= this.currentWidth; y /= this.currentHeight; this._centerPos[0] = x; this._centerPos[1] = y; this._reposition(); }, /\x2f Zoom by the given factor, centered around the given client position. adjustZoom: ({ratio, centerX, centerY}) => { if(this._slideshowMode) return; this._stopAnimation(); let [viewX,viewY] = this.clientToViewCoords([centerX,centerY]); /\x2f Store the position of the anchor before zooming, so we can restore it below. let center = this.getImagePosition([viewX, viewY]); /\x2f Apply the new zoom. Snap to 0 if we're very close, since it won't reach it exactly. let newFactor = this._zoomFactorCurrent * ratio; let newLevel = this.zoomFactorToZoomLevel(newFactor); if(Math.abs(newLevel) < 0.005) newLevel = 0; this._zoomLevel = newLevel; /\x2f Restore the center position. this.setImagePosition([viewX, viewY], center); this._reposition(); }, /\x2f Return the bounding box of where we want the position to stay. getBounds: () => { /\x2f Get the position that the image would normally be snapped to if it was in the /\x2f far top-left or bottom-right. let topLeft = this.getCurrentActualPosition({zoomPos: [0,0]}).zoomPos; let bottomRight = this.getCurrentActualPosition({zoomPos: [1,1]}).zoomPos; /\x2f If moveToTarget is true, we're animating for a double-tap zoom and we want to /\x2f center on this.targetZoomCenter. Adjust the target position so the image is still /\x2f clamped to the edge of the screen, and use that as both corners, so it's the only /\x2f place we can go. if(this.moveToTarget) { topLeft = this.getCurrentActualPosition({zoomPos: this.targetZoomCenter}).zoomPos; bottomRight = [...topLeft]; /\x2f copy } /\x2f Scale to view coordinates. topLeft[0] *= this.currentWidth; topLeft[1] *= this.currentHeight; bottomRight[0] *= this.currentWidth; bottomRight[1] *= this.currentHeight; /\x2f Convert to client coords. topLeft = this.viewToClientCoords(topLeft); bottomRight = this.viewToClientCoords(bottomRight); return new FixedDOMRect(topLeft[0], topLeft[1], bottomRight[0], bottomRight[1]); }, /\x2f When a fling starts (this includes releasing drags, even without a fling), decide /\x2f on the zoom factor we want to bounce to. onanimationstart: ({touchFactor=null, targetImagePos=null, moveToTarget=false}={}) => { this.moveToTarget = moveToTarget; /\x2f If we were given an explicit zoom factor to zoom to, use it. This happens /\x2f if we start the zoom in toggleZoom. if(touchFactor != null) { this.targetZoomFactor = touchFactor; this.targetZoomCenter = targetImagePos; return; } /\x2f Zoom relative to the center of the image. this.targetZoomCenter = [0.5, 0.5]; /\x2f If we're smaller than contain, always zoom up to contain. Also snap to contain /\x2f if we're slightly over, so we don't zoom to cover if cover and contain are nearby /\x2f and we're very close to contain. Don't give this much of a threshold, since it's /\x2f always easy to zoom to contain (just zoom out a bunch). /\x2f /\x2f Snap to cover if we're close to it. /\x2f /\x2f Otherwise, zoom to current, which is a no-op and will leave the zoom alone. let zoomFactorCover = this._zoomFactorCover; let zoomFactorCurrent = this._zoomFactorCurrent; if(this._zoomFactorCurrent < this._zoomFactorContain + 0.01) this.targetZoomFactor = this._zoomFactorContain; else if(Math.abs(zoomFactorCover - zoomFactorCurrent) < 0.15) this.targetZoomFactor = this._zoomFactorCover; else this.targetZoomFactor = this._zoomFactorCurrent; }, onanimationfinished: () => { /\x2f If we enabled moving towards a target position, disable it when the animation finishes. this.moveToTarget = false; /\x2f Save the zoom level for later images as either fit or cover. ppixiv.settings.set("zoom-level", this._zoomLevel == 0? 0:"cover"); }, /\x2f We don't want to zoom under zoom factor 1x. Return the zoom ratio needed to bring /\x2f the current zoom factor back up to 1x. For example, if the zoom factor is currently /\x2f 0.5, return 2. getWantedZoom: () => { /\x2f this.targetZoomCenter is in image coordinates. Return screen coordinates. let [viewX, viewY] = this.getViewPosFromImagePos(this.targetZoomCenter); let [centerX, centerY] = this.viewToClientCoords([viewX, viewY]); /\x2f ratio is the ratio we want to be applied relative to to the current zoom. return { ratio: this.targetZoomFactor / this._zoomFactorCurrent, centerX, centerY, }; }, }); } toggleZoom(e) { if(this._slideshowMode) return; /\x2f Stop any animation first, so we adjust the zoom relative to the level we finalize /\x2f the animation to. this._stopAnimation(); /\x2f Make sure TouchSScroller isn't animating. this._touchScroller.cancelFling(); /\x2f Toggle between fit (zoom level 0) and cover. If cover and fit are close together, /\x2f zoom to a higher factor instead of cover. This way we zoom to cover when it makes /\x2f sense, since it's a nicer zoom level to pan around in, but we use a higher level /\x2f if cover isn't enough of a zoom. First, figure out the zoom level we'll use if /\x2f we zoom in. let zoomInLevel; let zoomOutLevel = 0; let coverZoomRatio = 1 / this.zoomLevelToZoomFactor(0); if(coverZoomRatio > 1.5) zoomInLevel = this._zoomLevelCover; else { let scaledZoomFactor = this._zoomFactorCover*2; let scaledZoomLevel = this.zoomFactorToZoomLevel(scaledZoomFactor); zoomInLevel = scaledZoomLevel; } /\x2f Zoom to whichever one is further away from the current zoom. let currentZoomLevel = this.getZoomLevel(); let zoomDistanceIn = Math.abs(currentZoomLevel - zoomInLevel); let zoomDistanceOut = Math.abs(currentZoomLevel - zoomOutLevel); let level = zoomDistanceIn > zoomDistanceOut? zoomInLevel:zoomOutLevel; let touchFactor = this.zoomLevelToZoomFactor(level); /\x2f Our "screen" positions are relative to our container and not actually the /\x2f screen, but mouse events are relative to the screen. let viewPos = this.clientToViewCoords([e.clientX, e.clientY]); let targetImagePos = this.getImagePosition(viewPos); this._touchScroller.startFling({ onanimationstartOptions: { touchFactor, targetImagePos, /\x2f Set moveToTarget so we'll center on this position too. moveToTarget: true, } }); } _reposition({clampPosition=true, ...options}={}) { /\x2f This is called by the base class constructor before touchScroller is set up. if(this._touchScroller) { /\x2f Don't clamp the view position if we're repositioned while the touch scroller /\x2f is active. It handles overscroll and is allowed to go out of bounds. if(this._touchScroller.state != "idle") clampPosition = false; } return super._reposition({clampPosition, ...options}); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/viewer/images/mobile-viewer-images.js `), "/vview/viewer/images/viewer-images.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; import Viewer from '/vview/viewer/viewer.js'; import ImageEditor from '/vview/viewer/images/editing.js'; import ImageEditingOverlayContainer from '/vview/viewer/images/editing-overlay-container.js'; import Slideshow from '/vview/misc/slideshow.js'; import LocalAPI from '/vview/misc/local-api.js'; import DirectAnimation from '/vview/actors/direct-animation.js'; import { helpers, FixedDOMRect, OpenWidgets, GuardedRunner } from '/vview/misc/helpers.js'; /\x2f This is the viewer for static images. /\x2f /\x2f The base class for the main low-level image viewer. This handles loading images, /\x2f and the mechanics for zoom and pan. The actual zoom and pan UI is handled by the /\x2f desktop and mobile subclasses. /\x2f /\x2f We use two coordinate systems: /\x2f /\x2f - Image coordinates are unit coordinates, with 0x0 in the top-left and 1x1 in the bottom-right. /\x2f - View coordinates, with 0x0 in the top-left of the view. On desktop, this is usually /\x2f the same as the window, but it doesn't have to be (on mobile it may be adjusted to avoid /\x2f the statusbar). /\x2f /\x2f All sizing is relative to the view, so for most things we only need to know the aspect ratio /\x2f of the image and not its resolution. This allows us to start viewing a thumbnail and then /\x2f transition cleanly into viewing the full-size size when we only know the thumbnail's resolution. export default class ViewerImages extends Viewer { constructor({ /\x2f If set, this is a function returning a promise which resolves when any transitions /\x2f are complete. We'll wait until this resolves before switching to the full image to /\x2f reduce frame skips. waitForTransitions=() => { }, ...options }) { super({...options, template: \`
\`}); this._waitForTransitions = waitForTransitions; this._imageBox = this.root.querySelector(".image-box"); this._cropBox = this.root.querySelector(".crop-box"); this._refreshImageRunner = new GuardedRunner(this._signal); this._imageAspectRatio = 1; /\x2f The size of the real image, or null if we don't know it yet. this._actualWidth = null; this._actualHeight = null; this._ranPanAnimation = false; this._centerPos = [0, 0]; this._dragMovement = [0,0]; this._animations = { }; /\x2f Restore the most recent zoom mode. let enabled = ppixiv.settings.get("zoom-mode") == "locked"; let level = ppixiv.settings.get("zoom-level", "cover"); this.setZoom({ enabled, level }); this._imageContainer = new ImagesContainer({ container: this._cropBox }); this._editingContainer = new ImageEditingOverlayContainer({ container: this._cropBox, }); /\x2f Use a ResizeObserver to update our size and position if the window size changes. let resizeObserver = new ResizeObserver(this._onresize); resizeObserver.observe(this.root); this.shutdownSignal.addEventListener("abort", () => resizeObserver.disconnect()); this.root.addEventListener("dragstart", (e) => { if(!e.shiftKey) e.preventDefault(); }, this._signal); this.root.addEventListener("selectstart", (e) => e.preventDefault(), this._signal); /\x2f Start or stop panning if the user changes it while we're active, eg. by pressing ^P. ppixiv.settings.addEventListener("auto_pan", () => { /\x2f Allow the pan animation to start again when the auto_pan setting changes. this._ranPanAnimation = false; this._refreshAnimation(); }, this._signal); ppixiv.settings.addEventListener("slideshow_duration", this._refreshAnimationSpeed, this._signal); ppixiv.settings.addEventListener("auto_pan_duration", this._refreshAnimationSpeed, this._signal); /\x2f This is like pointermove, but received during quick view from the source tab. window.addEventListener("quickviewpointermove", this._quickviewpointermove, this._signal); /\x2f We pause changing to the next slideshow image UI widgets are open. Check if we should continue /\x2f when the open widget list changes. OpenWidgets.singleton.addEventListener("changed", () => this._checkAnimationFinished(), this._signal); ppixiv.mediaCache.addEventListener("mediamodified", ({mediaId}) => this._mediaInfoModified({mediaId}), this._signal); ppixiv.settings.addEventListener("upscaling", () => this._refreshFromMediaInfo(), this._signal); ppixiv.imageTranslations.addEventListener("translation-urls-changed", () => this._refreshFromMediaInfo(), this._signal); /\x2f Create the inpaint editor. if(!ppixiv.mobile) { this._imageEditor = new ImageEditor({ container: this.root, parent: this, overlayContainer: this._editingContainer, onvisibilitychanged: () => { this.refresh(); }, /\x2f refresh when crop editing is changed }); } } async load() { let { /\x2f If true, restore the pan/zoom position from history. If false, reset the position /\x2f for a new image. restoreHistory=false, /\x2f If set, we're in slideshow mode. We'll always start an animation, and image /\x2f navigation will be disabled. This can be null, "slideshow", or "loop". slideshow=false, onnextimage=null, } = this.options; this._shouldRestoreHistory = restoreHistory; this._slideshowMode = slideshow; this._onnextimage = onnextimage; /\x2f Tell the inpaint editor about the image. if(this._imageEditor) this._imageEditor.setMediaId(this.mediaId); /\x2f Refresh from whatever image info is already available. this._refreshFromMediaInfo(); /\x2f Load full info if it wasn't already loaded. await ppixiv.mediaCache.getMediaInfo(this.mediaId); /\x2f Stop if we were shutdown while we were async. if(this.shutdownSignal.aborted) return; /\x2f In case we only had preview info, refresh with the info we just loaded. this._refreshFromMediaInfo(); } /\x2f If media info changes, refresh in case any image URLs have changed. _mediaInfoModified({mediaId}) { if(mediaId != this.mediaId) return; this._refreshFromMediaInfo(); } refresh() { this._refreshFromMediaInfo(); } /\x2f Update this._image with as much information as we have so far and refresh the image. _refreshFromMediaInfo() { let imageInfo = this._getCurrentMediaInfo(); if(imageInfo == null) return; let { mediaInfo } = imageInfo; /\x2f If width is null, we only have the aspect ratio and not the actual size. let haveActualResolution = imageInfo.width != null; /\x2f If we're in "actual" zoom, we can't display anything until we have the real image /\x2f resolution. if(this.zoomActive && this._zoomLevel == "actual" && !haveActualResolution) return; /\x2f Get the pan and crop. let { pan, crop } = ppixiv.mediaCache.getExtraData(mediaInfo, this.mediaId); /\x2f Cropping is saved based on the real resolution, so if we have cropping, don't display /\x2f anything until we know the actual resolution. if(crop && !haveActualResolution) return; /\x2f Update any custom pan created by PanEditor. this._custom_animation = pan; /\x2f Disable cropping if the crop editor is active. if(this._imageEditor?.editingCrop) crop = null; let croppedSize = crop && crop.length == 4? new FixedDOMRect(crop[0], crop[1], crop[2], crop[3]):null; /\x2f If this is the real image size and not the thumbnail, store it. Once we have this, "actual" /\x2f zoom mode is available. if(imageInfo.width != null) { this._actualWidth = imageInfo.width; this._actualHeight = imageInfo.height; } /\x2f Set _imageAspectRatio. Use the crop size if we have one, otherwise the aspect ratio /\x2f we got from imageInfo. this._imageAspectRatio = imageInfo.aspectRatio; if(croppedSize) this._imageAspectRatio = croppedSize.width / croppedSize.height; /\x2f Set the size of the crop box. this._updateCrop(croppedSize); /\x2f Set the size of the image box and crop. this._setImageBoxSize(); /\x2f Set the initial zoom and image position if we haven't yet. if(!this._initialPositionSet) { this._setInitialImagePosition(this._shouldRestoreHistory); this._initialPositionSet = true; } this._reposition(); /\x2f Start refreshing the image with this data. let { url, previewUrl, inpaintUrl } = imageInfo; /\x2f Get the translation overlay URL if we have one. let translationUrl = ppixiv.imageTranslations.getTranslationUrl(this.mediaId); this._refreshImageRunner.call(this._refreshImage.bind(this), { url, previewUrl, translationUrl, inpaintUrl }); } /\x2f Return what we know of: /\x2f /\x2f { mediaInfo, url, previewUrl, inpaintUrl, aspectRatio, width, height } _getCurrentMediaInfo() { /\x2f See if full info is available. let mediaInfo = ppixiv.mediaCache.getMediaInfoSync(this.mediaId); let page = helpers.mediaId.parse(this.mediaId).page; if(mediaInfo != null) { let mangaPage = mediaInfo.mangaPages[page]; let { url, width, height } = mediaInfo.getMainImageUrl(page); return { mediaInfo, width, height, url, previewUrl: mangaPage.urls.small, inpaintUrl: mangaPage.urls.inpaint, aspectRatio: width / height, }; } /\x2f We don't have full data yet, so see if we have partial data. mediaInfo = ppixiv.mediaCache.getMediaInfoSync(this.mediaId, { full: false }); if(mediaInfo == null) return null; /\x2f Partial info has the image resolution and preview URL for the first page. let previewUrl = mediaInfo.previewUrls[page]; if(page == 0) { let { width, height } = mediaInfo; return { mediaInfo, width, height, previewUrl, aspectRatio: width / height, }; } /\x2f For other pages, see if we have the aspect ratio in ExtraCache. If we're in "actual" zoom /\x2f then we need the actual resolution, so we can't display anything until it's available. let aspectRatio = ppixiv.extraCache.getMediaAspectRatioSync(this.mediaId); if(aspectRatio == null) return null; return { mediaInfo, previewUrl, aspectRatio, }; } /\x2f Update this._cropBox to reflect the current crop. _updateCrop(croppedSize) { helpers.html.setClass(this._imageBox, "cropping", croppedSize != null); /\x2f If we're not cropping, just turn the crop box off entirely. if(croppedSize == null) { this._cropBox.style.width = "100%"; this._cropBox.style.height = "100%"; this._cropBox.style.transformOrigin = "0 0"; this._cropBox.style.transform = ""; return; } /\x2f We should always have the real image dimensions if we're displaying a cropped image. console.assert(this._actualWidth != null); /\x2f Crop the image by scaling up cropBox to cut off the right and bottom, /\x2f then shifting left and up. The size is relative to imageBox, so this /\x2f doesn't actually increase the image size. let cropWidth = croppedSize.width / this._actualWidth; let cropHeight = croppedSize.height / this._actualHeight; let cropLeft = croppedSize.left / this._actualWidth; let cropTop = croppedSize.top / this._actualHeight; this._cropBox.style.width = \`\${(1/cropWidth)*100}%\`; this._cropBox.style.height = \`\${(1/cropHeight)*100}%\`; this._cropBox.style.transformOrigin = "0 0"; this._cropBox.style.transform = \`translate(\${-cropLeft*100}%, \${-cropTop*100}%)\`; } /\x2f Refresh the image from imageInfo. async _refreshImage({ url, previewUrl, translationUrl, inpaintUrl, signal }) { /\x2f When quick view displays an image on mousedown, we want to see the mousedown too /\x2f now that we're displayed. if(this._pointerListener) this._pointerListener.checkMissedClicks(); /\x2f Don't show low-res previews during slideshows. if(this._slideshowMode) previewUrl = url; /\x2f If this is a local image, ask LocalAPI whether we should use the preview image for quick /\x2f loading. See shouldPreloadThumbs for details. if(!LocalAPI.shouldPreloadThumbs(this.mediaId, previewUrl)) previewUrl = null; /\x2f Set the image URLs. this._imageContainer.setImageUrls({ imageUrl: url, previewUrl, translationUrl, inpaintUrl }); /\x2f If the main image is already displayed, the image was already displayed and we're just /\x2f refreshing. if(this._imageContainer.displayedImage == "main") return; /\x2f Wait until the preview image (if we have one) is ready. This will finish quickly /\x2f if it's preloaded. /\x2f /\x2f We have to work around an API limitation: there's no way to abort decode(). If /\x2f a couple decode() calls from previous navigations are still running, this decode can /\x2f be queued, even though it's a tiny image and would finish instantly. If a previous /\x2f decode is still running, skip this and prefer to just add the image. It causes us /\x2f to flash a blank screen when navigating quickly, but image switching is more responsive. if(!ViewerImages.decoding) { try { await this._imageContainer.previewImage.decode(); } catch(e) { /\x2f Ignore exceptions from aborts. } } signal.throwIfAborted(); /\x2f Work around a Chrome quirk: even if an image is already decoded, calling img.decode() /\x2f will always delay and allow the page to update. This means that if we add the preview /\x2f image, decode the main image, then display the main image, the preview image will /\x2f flicker for one frame, which is ugly. Work around this: if the image is fully downloaded, /\x2f call decode() and see if it finishes quickly. If it does, we'll skip the preview and just /\x2f show the final image. /\x2f /\x2f On mobile we'd prefer to show the preview image than to delay the image at all, to minimize /\x2f gaps in the scroller interface. let imageReady = false; let decodePromise = null; if(!ppixiv.mobile) { if(url != null && this._imageContainer.complete) { decodePromise = this._decodeImage(this._imageContainer); /\x2f See if it finishes quickly. imageReady = await helpers.other.awaitWithTimeout(decodePromise, 50) != "timed-out"; } signal.throwIfAborted(); } /\x2f If the main image is already ready, show it. Otherwise, show the preview image. this._imageContainer.displayedImage = imageReady? "main":"preview"; /\x2f Let our caller know that we're showing something. this.ready.accept(true); /\x2f See if we have an animation to run. this._refreshAnimation(); /\x2f If the main image is already being displayed, we're done. if(this._imageContainer.displayedImage == "main") { /\x2f XXX: awkward special case this.pauseAnimation = false; return; } /\x2f If we don't have a main URL, stop here. We only have the preview to display. if(url == null) return; /\x2f If we're in slideshow mode, we aren't using the preview image. Pause the animation /\x2f until we actually display an image so it doesn't run while there's nothing visible. if(this._slideshowMode) this.pauseAnimation = true; /\x2f If the image isn't downloaded, load it now. this._imageContainer.decode will do this /\x2f too, but it doesn't support AbortSignal. if(!this._imageContainer.complete) { /\x2f Don't pass our abort signal to waitForImageLoad, since it'll clear the image on /\x2f cancellation. We don't want that here, since it'll interfere if we're just refreshing /\x2f and we'll clear the image ourselves when we're actually shut down. let result = await helpers.other.waitForImageLoad(this._imageContainer.mainImage); if(result != null) return; signal.throwIfAborted(); } /\x2f Wait for any transitions to complete before switching to the full image, so we don't /\x2f do it in the middle of transitions. This helps prevent frame hitches on mobile. On /\x2f desktop we may have already displayed the full image, but this is only important for /\x2f mobile. await this._waitForTransitions(); signal.throwIfAborted(); /\x2f Decode the image asynchronously before adding it. This is cleaner for large images, /\x2f since Chrome blocks the UI thread when setting up images. The downside is it doesn't /\x2f allow incremental loading. /\x2f /\x2f If we already have decodePromise, we already started the decode, so just wait for that /\x2f to finish. if(!decodePromise) decodePromise = this._decodeImage(this._imageContainer); await decodePromise; signal.throwIfAborted(); /\x2f If we paused an animation, resume it. this.pauseAnimation = false; this._imageContainer.displayedImage = "main"; } async _decodeImage(img) { /\x2f This is used to prevent requesting multiple large image decodes if they're /\x2f taking a while to finish. This is stored on the class, so it's shared across /\x2f viewers. ViewerImages.decoding = true; try { await img.decode(); } catch(e) { /\x2f Ignore exceptions from aborts. } finally { ViewerImages.decoding = false; } } _removeImages() { this._cancelSaveToHistory(); } onkeydown = async(e) => { if(e.ctrlKey || e.altKey || e.metaKey) return; switch(e.code) { case "Home": case "End": e.stopPropagation(); e.preventDefault(); let mediaInfo = await ppixiv.mediaCache.getMediaInfo(this.mediaId, { full: false }); if(mediaInfo == null) return; let newPage = e.code == "End"? mediaInfo.pageCount - 1:0; let newMediaId = helpers.mediaId.getMediaIdForPage(this.mediaId, newPage); ppixiv.app.showMediaId(newMediaId); return; } } shutdown() { this._stopAnimation({ shuttingDown: true }); this._cancelSaveToHistory(); super.shutdown(); } /\x2f Return "portrait" if the image is taller than the view, otherwise "landscape". get _relativeAspect() { /\x2f Figure out whether the image is relatively portrait or landscape compared to the view. let viewWidth = Math.max(this.viewWidth, 1); /\x2f might be 0 if we're hidden let viewHeight = Math.max(this.viewHeight, 1); let viewAspectRatio = viewWidth / viewHeight; return viewAspectRatio > this._imageAspectRatio? "portrait":"landscape"; } _setImageBoxSize() { this._imageBox.style.width = Math.round(this.width) + "px"; this._imageBox.style.height = Math.round(this.height) + "px"; } _onresize = (e) => { /\x2f Ignore resizes if we aren't displaying anything yet. if(this._imageContainer.displayedImage == null) return; this._setImageBoxSize(); this._reposition(); /\x2f If the window size changes while we have an animation running, update the animation. if(this._animationsRunning) this._refreshAnimation(); } /\x2f Enable or disable zoom lock. getLockedZoom() { return this._lockedZoom; } /\x2f Set the zoom level and whether zooming is enabled. If zooming is disabled, we still /\x2f remember the zoom level but display as if we're in "contain" mode. setZoom({ enabled=null, level=null, stopAnimation=true }={}) { if(stopAnimation) this._stopAnimation(); if(enabled != null) { /\x2f Zoom lock is always enabled on mobile. if(ppixiv.mobile) enabled = true; this._lockedZoom = enabled; ppixiv.settings.set("zoom-mode", enabled? "locked":"normal"); } if(level != null) { this._zoomLevel = level; ppixiv.settings.set("zoom-level", level); } this._reposition(); } /\x2f Relative zoom is applied on top of the main zoom. At 0, no adjustment is applied. /\x2f Positive values zoom in and negative values zoom out. getZoomLevel() { return this._zoomLevel; } /\x2f Convert between zoom levels and zoom factors. /\x2f /\x2f The zoom factor is the actual amount we zoom the image by, relative to its /\x2f base size (this.width and this.height). A zoom factor of 1 will fill the /\x2f view ("cover" mode). /\x2f /\x2f The zoom level is the user-facing exponential zoom, with a level of 0 fitting /\x2f the image inside the view ("contain" mode). zoomLevelToZoomFactor(level) { /\x2f Convert from an exponential zoom level to a linear zoom factor. let linear = Math.pow(1.5, level); /\x2f A factor of 1 is "cover" mode. Scale linear so linear == 1 results in "contain". return linear * this.containToCoverRatio; } zoomFactorToZoomLevel(factor) { /\x2f This is just the inverse of zoomLevelToZoomFactor. if(factor < 0.00001) { console.error(\`Invalid zoom factor \${factor}\`); factor = 1; } factor /= this.containToCoverRatio; return Math.log2(factor) / Math.log2(1.5); } /\x2f Get the effective zoom level. If zoom isn't active, the effective zoom level is 0 /\x2f (zoom factor 1). get _zoomLevelEffective() { if(!this.zoomActive) return 0; else return this._zoomLevelCurrent; } /\x2f Return the active zoom ratio. A zoom of 1x corresponds to "cover" zooming. get _zoomFactorEffective() { return this.zoomLevelToZoomFactor(this._zoomLevelEffective); } /\x2f Get this._zoomLevel with translating "cover" and "actual" translated to actual values. get _zoomLevelCurrent() { let level = this._zoomLevel; if(level == "cover") return this._zoomLevelCover; else if(level == "actual") return this._zoomLevelActual; else return level; } get _zoomFactorCurrent() { return this.zoomLevelToZoomFactor(this._zoomLevelCurrent); } /\x2f The zoom factor for cover mode. get _zoomFactorCover() { let result = Math.max(this.viewWidth/this.width, this.viewHeight/this.height) || 1; /\x2f If viewWidth/height is zero then we're hidden and have no size, so this zoom factor /\x2f isn't meaningful. Just make sure we don't return 0. return result == 0? 1:result; } get _zoomLevelCover() { return this.zoomFactorToZoomLevel(this._zoomFactorCover); } get _zoomFactorContain() { let result = Math.min(this.viewWidth/this.width, this.viewHeight/this.height) || 1; /\x2f If viewWidth/height is zero then we're hidden and have no size, so this zoom factor /\x2f isn't meaningful. Just make sure we don't return 0. return result == 0? 1:result; } get _zoomLevelContain() { return this.zoomFactorToZoomLevel(this._zoomFactorContain); } /\x2f The zoom factor for "actual" zoom. /\x2f /\x2f If we don't know the image dimensions yet, return 1. The caller should check _actualZoomAvailable /\x2f if needed. get _zoomFactorActual() { if(this._actualWidth == null) return 1; else return this._actualWidth / this.width; } get _zoomLevelActual() { return this.zoomFactorToZoomLevel(this._zoomFactorActual); } get _actualZoomAvailable() { return this._actualWidth != null; } /\x2f Zoom in or out. If zoom_in is true, zoom in by one level, otherwise zoom out by one level. changeZoom(zoomOut, { stopAnimation=true }={}) { if(stopAnimation) this._stopAnimation(); /\x2f zoomLevel can be a number. At 0 (default), we zoom to fit the image in the view. /\x2f Higher numbers zoom in, lower numbers zoom out. Zoom levels are logarithmic. /\x2f /\x2f zoomLevel can be "cover", which zooms to fill the view completely, so we only zoom on /\x2f one axis. /\x2f /\x2f zoomLevel can also be "actual", which zooms the image to its natural size. /\x2f /\x2f These zoom levels have a natural ordering, which we use for incremental zooming. Figure /\x2f out the zoom levels that correspond to "cover" and "actual". This changes depending on the /\x2f image and view size. /\x2f Increase or decrease relative_zoom_level by snapping to the next or previous increment. /\x2f We're usually on a multiple of increment, moving from eg. 0.5 to 0.75, but if we're on /\x2f a non-increment value from a special zoom level, this puts us back on the zoom increment. let oldLevel = this._zoomLevelEffective; let newLevel = oldLevel; let increment = 0.25; if(zoomOut) newLevel = Math.floor((newLevel - 0.001) / increment) * increment; else newLevel = Math.ceil((newLevel + 0.001) / increment) * increment; /\x2f If the amount crosses over one of the special zoom levels above, we select that instead. let crossed = function(oldValue, newValue, threshold) { return (oldValue < threshold && newValue > threshold) || (newValue < threshold && oldValue > threshold); }; if(crossed(oldLevel, newLevel, this._zoomLevelCover)) { console.log("Selected cover zoom"); newLevel = "cover"; } else if(this._actualZoomAvailable && crossed(oldLevel, newLevel, this._zoomLevelActual)) { console.log("Selected actual zoom"); newLevel = "actual"; } else { /\x2f Clamp relative zooming. Do this here to make sure we can always select cover and actual /\x2f which aren't clamped, even if the image is very large or small. newLevel = helpers.math.clamp(newLevel, -8, +8); } this.setZoom({ level: newLevel }); } /\x2f Return the image coordinate at a given view coordinate. getImagePosition(viewPos, {pos=null}={}) { if(pos == null) pos = this._currentZoomPos; return [ pos[0] + (viewPos[0] - this.viewWidth/2) / this.currentWidth, pos[1] + (viewPos[1] - this.viewHeight/2) / this.currentHeight, ]; } /\x2f Return the view coordinate for the given image coordinate (the inverse of getImagePosition). getViewPosFromImagePos(imagePos, {pos=null}={}) { if(pos == null) pos = this._currentZoomPos; return [ (imagePos[0] - pos[0]) * this.currentWidth + this.viewWidth/2, (imagePos[1] - pos[1]) * this.currentHeight + this.viewHeight/2, ]; } /\x2f Given a view position and a point on the image, return the centerPos needed /\x2f to align the point to that view position. getCenterForImagePosition(viewPos, zoomCenter) { return [ -((viewPos[0] - this.viewWidth/2) / this.currentWidth - zoomCenter[0]), -((viewPos[1] - this.viewHeight/2) / this.currentHeight - zoomCenter[1]), ]; } /\x2f Given a view position and a point on the image, align the point to the view /\x2f position. This has no effect when we're not zoomed. _reposition() must be called /\x2f after changing this. setImagePosition(viewPos, zoomCenter) { this._centerPos = this.getCenterForImagePosition(viewPos, zoomCenter); } _quickviewpointermove = (e) => { this.applyPointerMovement({movementX: e.movementX, movementY: e.movementY, fromQuickView: true}); } applyPointerMovement({movementX, movementY, fromQuickView=false}={}) { this._stopAnimation(); /\x2f Apply mouse dragging. let xOffset = movementX; let yOffset = movementY; if(!fromQuickView) { /\x2f Flip movement if we're on a touchscreen, or if it's enabled by the user. If this /\x2f is from quick view, the sender already did this. if(ppixiv.mobile || ppixiv.settings.get("invert-scrolling")) { xOffset *= -1; yOffset *= -1; } /\x2f Send pointer movements to linked tabs. If we're inverting scrolling, this /\x2f is included here, so clients will scroll the same way regardless of their /\x2f local settings. ppixiv.sendImage.sendMouseMovementToLinkedTabs(xOffset, yOffset); } /\x2f This will make mouse dragging match the image exactly: xOffset /= this.currentWidth; yOffset /= this.currentHeight; /\x2f Scale movement by the zoom factor, so we move faster if we're zoomed /\x2f further in. let zoomFactor = this._zoomFactorEffective; /\x2f This is a hack to keep the same panning sensitivity. The sensitivity was based on /\x2f _zoomFactorEffective being relative to "contain" mode, but it changed to "cover". /\x2f Adjust the panning speed so it's not affected by this change. zoomFactor /= this.containToCoverRatio; xOffset *= zoomFactor; yOffset *= zoomFactor; this._centerPos[0] += xOffset; this._centerPos[1] += yOffset; this._reposition(); } /\x2f Return true if zooming is active. get zoomActive() { return this._mousePressed || this.getLockedZoom(); } /\x2f Return the ratio to scale from the image's natural dimensions to cover the view, /\x2f filling it in both dimensions and only overflowing on one axis. This is zoom factor 1. /\x2f /\x2f The base dimensions of the image are (this._imageAspectRatio, 1), so we only need /\x2f the aspect ratio and not the dimensions. Use this.width and this.height to get dimensions /\x2f that are easier to work with. get _imageToCoverRatio() { let { viewWidth, viewHeight } = this; /\x2f In case we're hidden and have no width, make sure we don't return an invalid value. if(viewWidth == 0 || viewHeight == 0) return 1; return Math.max(viewWidth/this._imageAspectRatio, viewHeight); } /\x2f Return the ratio to scale from the image's natural dimensions to contain it to the /\x2f screen, filling the screen on one axis and not overflowing either axis. get _imageToContainRatio() { let { viewWidth, viewHeight } = this; /\x2f In case we're hidden and have no width, make sure we don't return an invalid value. if(viewWidth == 0 || viewHeight == 0) return 1; return Math.min(viewWidth/this._imageAspectRatio, viewHeight); } /\x2f Return the ratio from "contain" (fit the image in the view) to "cover" (cover the entire /\x2f view). get containToCoverRatio() { return this._imageToContainRatio / this._imageToCoverRatio; } /\x2f Return the width and height of the image when at zoom factor 1. get width() { return this._imageToCoverRatio * this._imageAspectRatio; } get height() { return this._imageToCoverRatio; } /\x2f The actual size of the image with its current zoom. get currentWidth() { return this.width * this._zoomFactorEffective; } get currentHeight() { return this.height * this._zoomFactorEffective; } /\x2f The dimensions of the image viewport. This can be 0 if the view is hidden. get viewWidth() { return this.root.offsetWidth || 1; } get viewHeight() { return this.root.offsetHeight || 1; } get _currentZoomPos() { if(this.zoomActive) return [this._centerPos[0], this._centerPos[1]]; else return [0.5, 0.5]; } /\x2f Convert [x,y] client coordinates to view coordinates. This is for events, which /\x2f give us client coordinates. clientToViewCoords([x,y]) { let { top, left } = this.root.getBoundingClientRect(); x -= left; y -= top; return [x,y]; } viewToClientCoords([x,y]) { let { top, left } = this.root.getBoundingClientRect(); x += left; y += top; return [x,y]; } get viewPosition() { /\x2f Animations always take up the whole view. if(this._animationsRunning) return new FixedDOMRect(0, 0, this.viewWidth, this.viewHeight); let viewWidth = Math.max(this.viewWidth, 1); let viewHeight = Math.max(this.viewHeight, 1); let { zoomPos } = this.getCurrentActualPosition(); let topLeft = this.getViewPosFromImagePos([0,0], { pos: zoomPos }); let bottomRight = this.getViewPosFromImagePos([1,1], { pos: zoomPos }); topLeft = [ helpers.math.clamp(topLeft[0], 0, viewWidth), helpers.math.clamp(topLeft[1], 0, viewHeight), ]; bottomRight = [ helpers.math.clamp(bottomRight[0], 0, viewWidth), helpers.math.clamp(bottomRight[1], 0, viewHeight), ]; return new FixedDOMRect( topLeft[0], topLeft[1], bottomRight[0], bottomRight[1]); } _reposition({clampPosition=true}={}) { if(this._imageContainer == null || !this._initialPositionSet) return; /\x2f Stop if we're being called after being disabled, or if we have no container /\x2f (our parent has been removed and we're being shut down). if(this.root == null || this.viewWidth == 0) return; /\x2f Update the rounding box with the new position. this._updateRoundingBox(); /\x2f Stop if there's an animation active. if(this._animationsRunning) return; this._scheduleSaveToHistory(); let { zoomPos, zoomFactor, imagePosition } = this.getCurrentActualPosition({clampPosition}); /\x2f Save the clamped position to centerPos, so after dragging off of the left edge, /\x2f dragging to the right starts moving immediately and doesn't drag through the clamped /\x2f distance. this._centerPos = zoomPos; this._imageBox.style.transform = \`translateX(\${imagePosition.x}px) translateY(\${imagePosition.y}px) scale(\${zoomFactor})\`; } /\x2f The rounding box is used when in notch mode to round the edge of the image. This /\x2f rounds the edge of the image to match the rounded edge of the phone, and moves /\x2f inwards so the rounding follows the image. /\x2f /\x2f The outer box applies the border-radius, and sets its top-left and bottom-right position /\x2f to match the position of the image in the view. The inner box inverts the translation, /\x2f so the image's actual position stays the same. _updateRoundingBox() { let roundedBox = this.querySelector(".rounded-box"); let roundedBoxReposition = this.querySelector(".rounded-box-reposition"); /\x2f This isn't used if we're not in notch mode. if(document.documentElement.dataset.displayMode != "notch") { roundedBox.style.translate = ""; roundedBoxReposition.style.translate = ""; roundedBox.style.width = ""; roundedBox.style.height = ""; return; } let { viewWidth, viewHeight } = this; /\x2f Distance from the top-left of the view to the image: let topLeft = this.getViewPosFromImagePos([0,0]); topLeft[0] = Math.max(0, topLeft[0]); topLeft[1] = Math.max(0, topLeft[1]); /\x2f Distance from the bottom-right of the view to the image: let bottomRight = this.getViewPosFromImagePos([1,1]); bottomRight[0] = viewWidth - bottomRight[0]; bottomRight[1] = viewHeight - bottomRight[1]; bottomRight[0] = Math.max(0, bottomRight[0]); bottomRight[1] = Math.max(0, bottomRight[1]); /\x2f If animations are running, just fill the screen, so we round at the very edges. /\x2f We don't update the rounding box during animations (we'd have to update every frame), /\x2f but animations always fill the screen, so if animations are running, just fill the /\x2f screen, so we round at the very edges. if(this._animationsRunning) { topLeft = [0,0]; bottomRight = [0,0]; } roundedBox.style.translate = \`\${topLeft[0]}px \${topLeft[1]}px\`; roundedBoxReposition.style.translate = \`\${-topLeft[0]}px \${-topLeft[1]}px\`; /\x2f Set the size of the rounding box. let size = [ viewWidth - topLeft[0] - bottomRight[0], viewHeight - topLeft[1] - bottomRight[1], ]; roundedBox.style.width = \`\${size[0]}px\`; roundedBox.style.height = \`\${size[1]}px\`; /\x2f Reduce the amount of rounding if we're not using a lot of the screen. For example, /\x2f if we're viewing a landscape image fit to a portrait screen and it only takes up /\x2f a small amount of the view, this will reduce the rounding so it's not too exaggerated. /\x2f It also gives the effect of the rounding scaling down if the image is pinch zoomed /\x2f very small. This only takes effect if there's a significant amount of unused screen /\x2f space, so most of the time the rounding stays the same. let horiz = helpers.math.scaleClamp(size[0] / viewWidth, .75, 0.35, 1, 0.25); let vert = helpers.math.scaleClamp(size[1] / viewHeight, .75, 0.35, 1, 0.25); roundedBox.style.setProperty("--rounding-amount", Math.min(horiz, vert)); } /\x2f Return the size and position of the image, given the current pan and zoom. /\x2f The returned zoomPos is centerPos after any clamping was applied for the current /\x2f position. getCurrentActualPosition({ zoomPos=null, /\x2f If false, edge clamping won't be applied. clampPosition=true, }={}) { /\x2f If the dimensions are empty then we aren't loaded. Clamp it to 1 so the math /\x2f below doesn't break. let width = Math.max(this.width, 1); let height = Math.max(this.height, 1); let viewWidth = Math.max(this.viewWidth, 1); let viewHeight = Math.max(this.viewHeight, 1); let zoomFactor = this._zoomFactorEffective; let zoomedWidth = width * zoomFactor; let zoomedHeight = height * zoomFactor; if(zoomPos == null) zoomPos = this._currentZoomPos; /\x2f Make sure we don't modify the caller's value. zoomPos = [...zoomPos]; /\x2f When we're zooming to fill the view, clamp panning so we always fill the view /\x2f and don't pan past the edge. if(clampPosition) { if(this.zoomActive && !ppixiv.settings.get("pan-past-edge")) { let topLeft = this.getImagePosition([0,0], { pos: zoomPos }); /\x2f minimum position topLeft[0] = Math.max(topLeft[0], 0); topLeft[1] = Math.max(topLeft[1], 0); zoomPos = this.getCenterForImagePosition([0,0], topLeft); let bottomRight = this.getImagePosition([viewWidth,viewHeight], { pos: zoomPos }); /\x2f maximum position bottomRight[0] = Math.min(bottomRight[0], 1); bottomRight[1] = Math.min(bottomRight[1], 1); zoomPos = this.getCenterForImagePosition([viewWidth,viewHeight], bottomRight); } /\x2f If we're narrower than the view, lock to the middle. /\x2f /\x2f Take the floor of these, so if we're covering a 1500x1200 window with a 1500x1200.2 image we /\x2f won't wiggle back and forth by one pixel. if(viewWidth >= Math.floor(zoomedWidth)) zoomPos[0] = 0.5; /\x2f center horizontally if(viewHeight >= Math.floor(zoomedHeight)) zoomPos[1] = 0.5; /\x2f center vertically } /\x2f _currentZoomPos is the position that should be centered in the view. At /\x2f [0.5,0.5], the image is centered. let x = viewWidth/2 - zoomPos[0]*zoomedWidth; let y = viewHeight/2 - zoomPos[1]*zoomedHeight; /\x2f If the display is 1:1 to the image, make sure there's no subpixel offset. Do this if /\x2f we're in "actual" zoom mode, or if we're in another zoom with the same effect, such as /\x2f if we're viewing a 1920x1080 image on a 1920x1080 screen and we're in "cover" mode. /\x2f If we're scaling the image at all due to zooming, allow it to be fractional to allow /\x2f smoother panning. let inActualZoomMode = this._actualZoomAvailable && Math.abs(this._zoomFactorEffective - this._zoomFactorActual) < 0.001; if(inActualZoomMode) { x = Math.round(x); y = Math.round(y); } return { zoomPos, zoomFactor, imagePosition: {x,y} }; } /\x2f Restore the pan and zoom state from history. /\x2f /\x2f restoreHistory is true if we're viewing an image that was in browser history and /\x2f we want to restore the pan/zoom position from history. /\x2f /\x2f If it's false, we're viewing a new image. We'll reset the image position, or restore /\x2f it selectively if "return to top" is disabled (view_mode != "manga"). _setInitialImagePosition(restoreHistory) { /\x2f If we were animating, start animating again. let args = helpers.args.location; if(args.state.zoom?.animating) this._refreshAnimation(); if(restoreHistory) { let level = args.state.zoom?.zoom; let enabled = args.state.zoom?.lock; this.setZoom({ level, enabled, stopAnimation: false }); } /\x2f Similar to how we display thumbnails for portrait images starting at the top, default to the top /\x2f if we'll be panning vertically when in cover mode. let aspect = this._relativeAspect; let centerPos = [0.5, aspect == "portrait"? 0:0.5]; /\x2f If history has a center position, restore it if we're restoring history. Also, restore it /\x2f if we're not in "return to top" mode as long as the aspect ratios of the images are similar, /\x2f eg. we're going from a portait image to another portrait image. if(args.state.zoom != null) { let oldAspect = args.state.zoom?.relativeAspect; let returnToTop = ppixiv.settings.get("view_mode") == "manga"; if(restoreHistory || (!returnToTop && aspect == oldAspect)) centerPos = [...args.state.zoom?.pos]; } this._centerPos = centerPos; } /\x2f Save the pan and zoom state to history. _saveToHistory = () => { /\x2f Store the pan position at the center of the view. let args = helpers.args.location; args.state.zoom = { pos: this._centerPos, zoom: this.getZoomLevel(), lock: this.getLockedZoom(), relativeAspect: this._relativeAspect, animating: this._animationsRunning, }; helpers.navigate(args, { addToHistory: false }); } /\x2f Schedule _saveToHistory to run. This is buffered so we don't call history.replaceState /\x2f too quickly. _scheduleSaveToHistory() { /\x2f If we're called repeatedly, allow the first timer to complete, so we save /\x2f periodically during drags or flings that are taking a long time to finish /\x2f rather than not saving at all. if(this._saveToHistoryId) return; this._saveToHistoryId = realSetTimeout(() => { this._saveToHistoryId = null; /\x2f Work around a Chrome bug: updating history causes the mouse cursor to become visible /\x2f for one frame, which causes it to flicker while panning around. Updating history state /\x2f shouldn't affect the UI at all. Work around this by just rescheduling the save if the /\x2f mouse is currently pressed. if(this._mousePressed) { this._scheduleSaveToHistory(); return; } this._saveToHistory(); }, 250); } _cancelSaveToHistory() { if(this._saveToHistoryId != null) { realClearTimeout(this._saveToHistoryId); this._saveToHistoryId = null; } } _createCurrentAnimation() { /\x2f Decide which animation mode to use. let animationMode; if(this._slideshowMode == "loop") animationMode = "loop"; else if(this._slideshowMode != null) animationMode = "slideshow"; else if(ppixiv.settings.get("auto_pan")) animationMode = "auto-pan"; else return { }; /\x2f Sanity check: this.root should always have a size. If this is 0, the container /\x2f isn't visible and we don't know anything about how big we are, so we can't set up /\x2f the slideshow. This is this.viewWidth below. if(this.root.offsetHeight == 0) console.warn("Image container has no size"); let slideshow = new Slideshow({ /\x2f this.width/this.height are the size of the image at 1x zoom, which is to fit /\x2f onto the view. Scale this up by zoomFactorCover, so the slideshow's default /\x2f zoom level is to cover the view. width: this.width, height: this.height, containerWidth: this.viewWidth, containerHeight: this.viewHeight, mode: animationMode, /\x2f Don't zoom below "contain". minimumZoom: this.zoomLevelToZoomFactor(0), }); /\x2f Create the animation. let animation = slideshow.getAnimation(this._custom_animation); /\x2f If the viewer is created for a mobile drag, skip the fade-in, so it doesn't fade in /\x2f while dragging. if(this.options.displayedByDrag) animation.fadeIn = 0; return { animationMode, animation }; } /\x2f Start a pan/zoom animation. If it's already running, update it in place. _refreshAnimation() { /\x2f Create the animation. let { animationMode, animation } = this._createCurrentAnimation(); if(animation == null) { this._stopAnimation(); return; } /\x2f In slideshow-hold, delay between each alternation to let the animation settle visually. /\x2f /\x2f The animation API makes this a pain, since it has no option to delay between alternations. /\x2f We have to add it as an offset at both ends of the animation, and then increase the duration /\x2f to compensate. let iterationStart = 0; if(animationMode == "loop") { /\x2f To add a 1 second delay to both ends of the alternation, add 0.5 seconds of delay /\x2f to both ends (the delay will be doubled by the alternation), and increase the /\x2f total length by 1 second. let delay = 1; animation.duration += delay; let fraction = (delay*0.5) / animation.duration; /\x2f We can set iterationStart to skip the delay the first time through. For now we don't /\x2f do this, so we pause at the start after the fade-in. /\x2f iterationStart = fraction; animation.keyframes = [ { ...animation.keyframes[0], offset: 0 }, { ...animation.keyframes[0], offset: fraction }, { ...animation.keyframes[1], offset: 1-fraction }, { ...animation.keyframes[1], offset: 1 }, ] } /\x2f If the mode isn't changing, just update the existing animation in place, so we /\x2f update the animation if the window is resized. if(this._currentAnimationMode == animationMode) { /\x2f On iOS leave the animation alone, since modifying animations while they're /\x2f running is broken on iOS and just cause the animation to freeze, and restarting /\x2f the animation when we regain focus looks ugly. if(ppixiv.ios) return; this._animations.main.effect.setKeyframes(animation.keyframes); this._animations.main.updatePlaybackRate(1 / animation.duration); return; } /\x2f If we're in pan mode and we've already run the pan animation for this image, don't /\x2f start it again. if(animationMode == "auto-pan") { if(this._ranPanAnimation) return; this._ranPanAnimation = true; } /\x2f Stop the previous animations. this._stopAnimation(); this._currentAnimationMode = animationMode; /\x2f Create the main animation. this._animations.main = new DirectAnimation(new KeyframeEffect( this._imageBox, animation.keyframes, { /\x2f The actual duration is set by updatePlaybackRate. duration: 1000, fill: 'forwards', direction: animationMode == "loop"? "alternate":"normal", iterations: animationMode == "loop"? Infinity:1, iterationStart, } )); /\x2f Set the speed. Setting it this way instead of with the duration lets us change it smoothly /\x2f if settings are changed. this._animations.main.updatePlaybackRate(1 / animation.duration); this._animations.main.onfinish = this._checkAnimationFinished; /\x2f If this animation wants a fade-in and a previous one isn't still playing, start it. /\x2f Note that we use Animation and not DirectAnimation for fades, since DirectAnimation won't /\x2f sleep during the long delay while they're not doing anything. if(animation.fadeIn > 0) this._animations.fadeIn = Slideshow.makeFadeIn(this._imageBox, { duration: animation.fadeIn * 1000 }); /\x2f Create the fade-out. if(animation.fadeOut > 0) { this._animations.fadeOut = Slideshow.makeFadeOut(this._imageBox, { duration: animation.fadeIn * 1000, delay: (animation.duration - animation.fadeOut) * 1000, }); } /\x2f Start the animations. If any animation is finished, it was inherited from a /\x2f previous animation, so don't call play() since that'll restart it. for(let animation of Object.values(this._animations)) { if(animation.playState != "finished") animation.play(); } /\x2f Make sure the rounding box is disabled during the animation. this._updateRoundingBox(); } _checkAnimationFinished = async(e) => { if(this._animations.main?.playState != "finished") return; /\x2f If we're not in slideshow mode, just clean up the animation and stop. We should /\x2f never get here in slideshow-hold. if(this._currentAnimationMode != "slideshow" || !this._onnextimage) { this._stopAnimation(); return; } /\x2f Don't move to the next image while the user has a popup open. We'll return here when /\x2f dialogs are closed. if(!OpenWidgets.singleton.empty) { console.log("Deferring next image while UI is open"); return; } /\x2f Tell the caller that we're ready for the next image. Don't call stopAnimation yet, /\x2f so we don't cancel opacity and cause the image to flash onscreen while the new one /\x2f is loading. We'll stop if when onnextimage navigates. let { mediaId } = await this._onnextimage(this); /\x2f onnextimage normally navigates to the next slideshow image. If it didn't, call /\x2f stopAnimation so we clean up the animation and make it visible again if it's faded /\x2f out. This typically only happens if we only have one image. if(mediaId == null) { console.log("The slideshow didn't have a new image. Resetting the slideshow animation"); this._stopAnimation(); } } /\x2f Update just the animation speed, so we can smoothly show changes to the animation /\x2f speed as the user changes it. _refreshAnimationSpeed = () => { if(!this._animationsRunning) return; /\x2f Don't update keyframes, since changing the speed can change keyframes too, /\x2f which will jump when we set them. Just update the playback rate. let { animation } = this._createCurrentAnimation(); this._animations.main.updatePlaybackRate(1 / animation.duration); } /\x2f If an animation is running, cancel it. /\x2f /\x2f keepAnimations is a list of animations to leave running. For example, ["fadeIn"] will leave /\x2f any fade-in animation alone. _stopAnimation({ keepAnimations=[], /\x2f If true, just shut down the animation. If false, the zoom level will be set to match /\x2f where the animation left off, which is used when exiting from pan mode. shuttingDown=false, }={}) { /\x2f Only continue if we have a main animation. If we don't have an animation, we don't /\x2f want to modify the zoom/pan position and there's nothing to stop. if(!this._animations.main) return false; /\x2f Commit the current state of the main animation so we can read where the image was. let appliedAnimations = true; try { for(let [name, animation] of Object.entries(this._animations)) { if(keepAnimations.indexOf(name) != -1) continue; animation.commitStyles(); } } catch { appliedAnimations = false; } /\x2f Cancel all animations. We don't need to wait for animation.pending here. for(let [name, animation] of Object.entries(this._animations)) { if(keepAnimations.indexOf(name) != -1) continue; animation.cancel(); delete this._animations[name]; } /\x2f Make sure we don't leave the image faded out if we stopped while in the middle /\x2f of a fade. this._imageBox.style.opacity = ""; this._currentAnimationMode = null; if(!appliedAnimations) { /\x2f For some reason, commitStyles throws an exception if we're not visible, which happens /\x2f if we're shutting down. In this case, just cancel the animations. return true; } /\x2f Pull out the transform and scale we were left on when the animation stopped. let matrix = new DOMMatrix(getComputedStyle(this._imageBox).transform); let zoomFactor = matrix.a, left = matrix.e, top = matrix.f; let zoomLevel = this.zoomFactorToZoomLevel(zoomFactor); /\x2f If we're shutting down, all we need to do is clean up the animation. Make sure we don't /\x2f change the saved zoom mode, since another viewer may already be active. if(shuttingDown) return; /\x2f Apply the current zoom and pan position. If the zoom level is 0 then just disable /\x2f zoom, and use "cover" if the zoom level matches it. The zoom we set here doesn't /\x2f have to be one that's selectable in the UI. Be sure to set stopAnimation, so these /\x2f setZoom, etc. calls don't recurse into here. if(Math.abs(zoomLevel) < 0.001) this.setZoom({ enabled: false, stopAnimation: false }); else if(Math.abs(zoomLevel - this._zoomLevelCover) < 0.01) this.setZoom({ enabled: true, level: "cover", stopAnimation: false }); else this.setZoom({ enabled: true, level: zoomLevel, stopAnimation: false }); /\x2f Set the image position to match where the animation left it. this.setImagePosition([left, top], [0,0]); this._reposition(); return true; } get _animationsRunning() { return this._animations.main != null; } set pauseAnimation(pause) { this._pauseAnimation = pause; this.refreshAnimationPaused(); } /\x2f The animation is paused if we're explicitly paused while loading, or if something is /\x2f open over the image and registered with OpenWidgets, like the context menu. refreshAnimationPaused() { /\x2f Note that playbackRate is broken on iOS. for(let animation of Object.values(this._animations)) { /\x2f If an animation is finished, don't restart it, or it'll rewind. if(this._pauseAnimation && animation.playState == "running") animation.pause(); else if(!this._pauseAnimation && animation.playState == "paused") animation.play(); } } /\x2f These zoom helpers are mostly for the popup menu. /\x2f /\x2f Toggle zooming, centering around the given view position, or the center of the /\x2f view if x and y are null. zoomToggle({x, y}={}) { if(this._slideshowMode) return; this._stopAnimation(); if(x == null || y == null) { x = this.viewWidth / 2; y = this.viewHeight / 2; } let center = this.getImagePosition([x, y]); this.setZoom({ enabled: !this.getLockedZoom() }); this.setImagePosition([x, y], center); this._reposition(); } /\x2f Set the zoom level, keeping the given view position stationary if possible. zoomSetLevel(level, {x, y}) { if(this._slideshowMode) return; /\x2f Ignore requests for actual zooming if we don't know the actual image size yet. if(level == "actual" && this._actualWidth == null) { console.log("Can't display actual zoom yet"); return; } this._stopAnimation(); /\x2f If the zoom level that's already selected is clicked and we're already zoomed, /\x2f just toggle zoom as if the toggle zoom button was pressed. if(this.getZoomLevel() == level && this.getLockedZoom()) { this.setZoom({ enabled: false }); this._reposition(); return; } let center = this.getImagePosition([x, y]); /\x2f Each zoom button enables zoom lock, since otherwise changing the zoom level would /\x2f only have an effect when click-dragging, so it looks like the buttons don't do anything. this.setZoom({ enabled: true, level }); this.setImagePosition([x, y], center); this._reposition(); } /\x2f Zoom in or out, keeping x,y centered if possible. If x and y are null, center around /\x2f the center of the view. zoomAdjust(down, {x, y}) { if(this._slideshowMode) return; this._stopAnimation(); if(x == null || y == null) { x = this.viewWidth / 2; y = this.viewHeight / 2; } let center = this.getImagePosition([x, y]); /\x2f If mousewheel zooming is used while not zoomed, turn on zooming and set /\x2f a 1x zoom factor, so we zoom relative to the previously unzoomed image. if(!this.zoomActive) this.setZoom({ enabled: true, level: 0 }); let oldZoomLevel = this._zoomLevelEffective; this.changeZoom(down); let newZoomLevel = this._zoomLevelEffective; /\x2f If the zoom level didn't change, try one more time. For example, if cover mode /\x2f is equal to zoom level 2 and we just switched between them, we've changed zoom /\x2f modes but nothing will actually change, so we should skip to the next level. if(Math.abs(oldZoomLevel - newZoomLevel) < 0.01) this.changeZoom(down); /\x2f If we're selecting zoom level 0 (contain), set the zoom level to cover and turn /\x2f off zoom lock. That displays the same thing, but clicking the image will zoom /\x2f to cover, which is more natural. if(this.getZoomLevel() == 0) this.setZoom({ enabled: false, level: "cover" }); this.setImagePosition([x, y], center); this._reposition(); } } /\x2f A helper that holds all of the images that we display together. /\x2f /\x2f Beware of a Firefox bug: if we set the image to helpers.other.blankImage to prevent it /\x2f from being shown as a broken image initially, image.decode() breaks and always resolves /\x2f immediately for the new image. class ImagesContainer extends Widget { constructor({ ...options }) { super({...options, template: \`
\`}); this.mainImage = this.root.querySelector(".main-image"); this.inpaintImage = this.root.querySelector(".inpaint-image"); this.translationImage = this.root.querySelector(".translation-image"); this.previewImage = this.root.querySelector(".low-res-preview"); } shutdown() { /\x2f Clear the image URLs when we remove them, so any loads are cancelled. This seems to /\x2f help Chrome with GC delays. if(this.mainImage) { this.mainImage.src = helpers.other.blankImage; this.mainImage.remove(); this.mainImage = null; } if(this.previewImage) { this.previewImage.src = helpers.other.blankImage; this.previewImage.remove(); this.previewImage = null; } super.shutdown(); } setImageUrls({ imageUrl, inpaintUrl, translationUrl, previewUrl }) { /\x2f Work around an ancient legacy browser mess: img.src is "" by default (no image), but if /\x2f you set it back to "", it ends up being resolved as an empty URL and getting set to window.location, /\x2f and causing bogus network requests and errors. We have to manually remove the attribute /\x2f instead to work around this. function setImageSource(img, src) { if(src) img.src = src; else img.removeAttribute("src"); } setImageSource(this.mainImage, imageUrl); setImageSource(this.inpaintImage, inpaintUrl); setImageSource(this.translationImage, translationUrl); setImageSource(this.previewImage, previewUrl); this._refreshInpaintVisibility(); } get complete() { return this.mainImage.complete && this.inpaintImage.complete && this.translationImage.complete; } decode() { let promises = []; if(this.mainImage.src) promises.push(this.mainImage.decode()); if(this.inpaintImage.src) promises.push(this.inpaintImage.decode()); if(this.translationImage.src) promises.push(this.translationImage.decode()); return Promise.all(promises); } /\x2f Set whether the main image or preview image are visible. set displayedImage(displayedImage) { this.mainImage.hidden = displayedImage != "main"; this.previewImage.hidden = displayedImage != "preview"; this._refreshInpaintVisibility(); } get displayedImage() { if(!this.mainImage.hidden) return "main"; else if(!this.previewImage.hidden) return "preview"; else return null; } /\x2f inpaintImage and translationImage are visible when the main image is, but only if they have an image. _refreshInpaintVisibility() { this.inpaintImage.hidden = this.mainImage.hidden || !this.inpaintImage.src; this.translationImage.hidden = this.mainImage.hidden || !this.translationImage.src; } get width() { return this.mainImage.width; } get height() { return this.mainImage.height; } get naturalWidth() { return this.mainImage.naturalWidth; } get naturalHeight() { return this.mainImage.naturalHeight; } get hideInpaint() { return this.inpaintImage.style.opacity == 0; } set hideInpaint(value) { this.inpaintImage.style.opacity = value? 0:1; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/viewer/images/viewer-images.js `), "/vview/viewer/video/seek-bar.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; import DragHandler from '/vview/misc/drag-handler.js'; import { helpers } from '/vview/misc/helpers.js'; export default class SeekBar extends Widget { constructor({...options}) { super({...options, template: \`
\` }); this.currentTime = 0; this.duration = 1; this.amountLoaded = 1; this.refresh(); this.setCallback(null); this.dragger = new DragHandler({ element: this.root, signal: this.shutdownSignal, name: "seek-bar", /\x2f Don't delay the start of seek bar drags until the first pointer movement. deferredStart: () => false, confirmDrag: () => { /\x2f Never start dragging while we have no callback. This generally shouldn't happen /\x2f since we should be hidden. return this.callback != null; }, ondragstart: ({event}) => { helpers.html.setClass(this.root, "dragging", true); this.setDragPos(event); return true; }, ondrag: ({event, first}) => { this.setDragPos(event); }, ondragend: () => { helpers.html.setClass(this.root, "dragging", false); if(this.callback) this.callback(false, null); }, }); }; /\x2f The user clicked or dragged. Pause and seek to the clicked position. setDragPos(e) { /\x2f Get the mouse position relative to the seek bar. let bounds = this.root.getBoundingClientRect(); let pos = (e.clientX - bounds.left) / bounds.width; pos = Math.max(0, Math.min(1, pos)); let time = pos * this.duration; /\x2f Tell the user to seek. this.callback(true, time); } /\x2f Set the callback. callback(pause, time) will be called when the user interacts /\x2f with the seek bar. The first argument is true if the video should pause (because /\x2f the user is dragging the seek bar), and time is the desired playback time. If callback /\x2f is null, remove the callback. setCallback(callback) { if(this.callback == callback) return; /\x2f Stop dragging on any previous caller before we replace the callback. if(this.callback != null) this.dragger.cancelDrag(); this.callback = callback; }; setDuration(seconds) { this.duration = seconds; this.refresh(); }; setCurrentTime(seconds) { this.currentTime = seconds; this.refresh(); }; /\x2f Set the amount of the video that's loaded. If 1 or greater, the loading indicator will be /\x2f hidden. setLoaded(value) { this.amountLoaded = value; this.refresh(); } refresh() { let position = this.duration > 0.0001? (this.currentTime / this.duration):0; this.root.querySelector(".seek-fill").style.width = (position * 100) + "%"; let loaded = this.amountLoaded < 1? this.amountLoaded:0; this.root.querySelector(".seek-loaded").style.width = (loaded * 100) + "%"; }; } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/viewer/video/seek-bar.js `), "/vview/viewer/video/video-ui.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; import SeekBar from '/vview/viewer/video/seek-bar.js'; import PointerListener from '/vview/actors/pointer-listener.js'; import { helpers, ClassFlags } from '/vview/misc/helpers.js'; /\x2f The overlay video UI. export default class VideoUI extends Widget { constructor({...options}) { super({ ...options, template: \`
\${ helpers.createIcon("pause", { dataset: { play: "pause" }}) } \${ helpers.createIcon("play_arrow", { dataset: { play: "play" }}) }
\${ helpers.createIcon("volume_up", { dataset: { volume: "high" }}) } \${ helpers.createIcon("volume_off", { dataset: { volume: "mute" }}) } \${ helpers.createIcon("picture_in_picture_alt") }
\`}); /\x2f We set .show-ui to force the video control bar to be displayed when the mobile UI /\x2f is visible. this.refreshShowUi(); /\x2f listen for data-mobile-ui-visible and show our UI ClassFlags.get.addEventListener("mobile-ui-visible", (e) => { this.refreshShowUi(); }, { signal: this.shutdownSignal }); /\x2f Set .dragging to stay visible during drags. new PointerListener({ element: this.root, callback: (e) => { helpers.html.setClass(this.root, "dragging", e.pressed); }, }); /\x2f Add the seek bar. This moves between seek-bar-container-top and seek-bar-container-bottom. this.seekBar = new SeekBar({ container: this.root.querySelector(".seek-bar-container-top"), }); this._setSeekBarPos(); this.volumeSlider = new VolumeSliderWidget({ container: this.root.querySelector(".volume-slider-container"), startedDragging: () => { /\x2f Remember what the volume was before the drag started. this.savedVolume = this.video.volume; }, stoppedDragging: () => { this.savedVolume = null; }, ondrag: (volume) => { if(!this.video) return; /\x2f Dragging the volume slider to 0 mutes and resets the underlying volume. if(volume == 0) { this.video.volume = this.savedVolume; this.video.muted = true; } else { this.video.volume = volume; this.video.muted = false; } }, }); this.time = this.root.querySelector(".time"); /\x2f Prevent dblclick from propagating to our parent, so double-clicking inside the /\x2f UI strip doesn't toggle fullscreen. this.root.addEventListener("dblclick", (e) => { e.stopPropagation(); e.preventDefault(); }); this.root.querySelector(".play-button").addEventListener("click", () => { if(this.player != null) this.player.setWantPlaying(!this.player.wantPlaying); }, { signal: this.shutdownSignal }); for(let button of this.root.querySelectorAll("[data-volume]")) button.addEventListener("click", () => { if(this.video == null) return; this.video.muted = !this.video.muted; }, { signal: this.shutdownSignal }); this.root.querySelector(".pip-button").addEventListener("click", async () => { if(this.video == null) return; if(this.video.requestPictureInPicture == null) return false; try { await this.video.requestPictureInPicture(); return true; } catch(e) { return false; } }, { signal: this.shutdownSignal }); document.addEventListener("fullscreenchange", (e) => { this._setSeekBarPos(); }, { signal: this.shutdownSignal }); window.addEventListener("resize", (e) => { this._setSeekBarPos(); }, { signal: this.shutdownSignal }); /\x2f Set up the fullscreen button. Disable this on mobile, since it doesn't make sense there. let fullscreenButton = this.root.querySelector(".fullscreen"); fullscreenButton.hidden = ppixiv.mobile; fullscreenButton.addEventListener("click", () => { helpers.toggleFullscreen(); }, { signal: this.shutdownSignal }); this.videoChanged(); } refreshShowUi() { let show_ui = ClassFlags.get.get("mobile-ui-visible"); helpers.html.setClass(this.root, "show-ui", show_ui); } /\x2f Set whether the seek bar is above or below the video UI. _setSeekBarPos() { /\x2f Insert the seek bar into the correct container. let top = ppixiv.mobile || !helpers.isFullscreen(); this.seekBar.root.remove(); let seekBarContainer = top? ".seek-bar-container-top":".seek-bar-container-bottom"; this.root.querySelector(seekBarContainer).appendChild(this.seekBar.root); this.seekBar.root.dataset.position = top? "top":"bottom"; } shutdown() { /\x2f Remove any listeners. this.videoChanged(); super.shutdown(); } videoChanged({player=null, video=null}={}) { if(this.removeVideoListeners) { this.removeVideoListeners.abort(); this.removeVideoListeners = null; } this.player = player; this.video = video; /\x2f Only display the main UI when we have a video. Don't hide the seek bar, since /\x2f it's also used by ViewerUgoira. this.root.querySelector(".video-ui-strip").hidden = this.video == null; if(this.video == null) return; this.removeVideoListeners = new AbortController(); this.video.addEventListener("volumechange", (e) => { this.volumeChanged(); }, { signal: this.removeVideoListeners.signal }); this.video.addEventListener("play", (e) => { this.pauseChanged(); }, { signal: this.removeVideoListeners.signal }); this.video.addEventListener("pause", (e) => { this.pauseChanged(); }, { signal: this.removeVideoListeners.signal }); this.video.addEventListener("timeupdate", (e) => { this.timeChanged(); }, { signal: this.removeVideoListeners.signal }); this.video.addEventListener("loadedmetadata", (e) => { this.timeChanged(); }, { signal: this.removeVideoListeners.signal }); this.video.addEventListener("progress", (e) => { this.timeChanged(); }, { signal: this.removeVideoListeners.signal }); /\x2f Hide the PIP button if the browser or this video doesn't support it. this.root.querySelector(".pip-button").hidden = this.video.requestPictureInPicture == null; this.pauseChanged(); this.volumeChanged(); this.timeChanged(); } pauseChanged() { this.root.querySelector("[data-play='play']").style.display = !this.video.paused? "":"none"; this.root.querySelector("[data-play='pause']").style.display = this.video.paused? "":"none"; } volumeChanged() { if(this.video.hideAudioControls) { for(let element of this.root.querySelectorAll("[data-volume]")) element.style.display = "none"; this.volumeSlider.root.hidden = true; } else { /\x2f Update the displayed volume icon. When not muted, scale opacity based on the volume. let opacity = (this.video.volume * 0.75) + 0.25; this.root.querySelector("[data-volume='high']").style.display = !this.video.muted? "":"none"; this.root.querySelector("[data-volume='high']").style.opacity = opacity; this.root.querySelector("[data-volume='mute']").style.display = this.video.muted? "":"none"; /\x2f Update the volume slider. If the video is muted, display 0 instead of the /\x2f underlying volume. this.volumeSlider.root.hidden = false; this.volumeSlider.setValue(this.video.muted? 0:this.video.volume); } } timeChanged() { if(this.video == null) return; let duration = this.video.duration; let now = this.video.currentTime; if(isNaN(duration)) { this.time.innerText = ""; return; } if(duration < 10) { let fmt = (totalSeconds) => { let seconds = Math.floor(totalSeconds); let ms = Math.round((totalSeconds * 1000) % 1000); return "" + seconds + "." + ms.toString().padStart(3, '0'); }; this.time.innerText = \`\${fmt(now)} / \${fmt(duration)}\`; } else { this.time.innerText = \`\${helpers.strings.formatSeconds(now)} / \${helpers.strings.formatSeconds(duration)}\`; } } } class VolumeSliderWidget extends Widget { constructor({ ondrag, startedDragging, stoppedDragging, ...options }) { super({ ...options, template: \`
\` }); this.ondrag = ondrag; this.startedDragging = startedDragging; this.stoppedDragging = stoppedDragging; this.volumeLine = this.root.querySelector(".volume-line"); new PointerListener({ element: this.root, callback: (e) => { if(e.pressed) { this.startedDragging(); this._capturedPointerId = e.pointerId; this.root.setPointerCapture(this._capturedPointerId); this.root.addEventListener("pointermove", this.pointermove); this.handleDrag(e); } else { this.stopDragging(); } }, }); } get isDragging() { return this._capturedPointerId != null; } pointermove = (e) => { this.handleDrag(e); } stopDragging() { this.stoppedDragging(); this.root.removeEventListener("pointermove", this.pointermove); if(this._capturedPointerId != null) { this.root.releasePointerCapture(this._capturedPointerId); this._capturedPointerId = null; } } setValue(value) { /\x2f Ignore external changes while we're dragging. if(this.isDragging) return; this.setValueInternal(value); } setValueInternal(value) { value = 1 - value; this.volumeLine.style.background = \`linear-gradient(to left, #000 \${value*100}%, #FFF \${value*100}px)\`; } handleDrag(e) { /\x2f Get the mouse position relative to the volume slider. let {left, width} = this.volumeLine.getBoundingClientRect(); let volume = (e.clientX - left) / width; volume = Math.max(0, Math.min(1, volume)); this.setValueInternal(volume); this.ondrag(volume); }; } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/viewer/video/video-ui.js `), "/vview/viewer/video/viewer-ugoira.js": loadBlob("application/javascript", `import ViewerVideoBase from '/vview/viewer/video/viewer-video-base.js'; import ZipImagePlayer from '/vview/widgets/zip-image-player.js'; import { helpers } from '/vview/misc/helpers.js'; export default class ViewerUgoira extends ViewerVideoBase { constructor({...options}) { super({...options}); /\x2f Create a canvas to render into. this.video = document.createElement("canvas"); this.video.hidden = true; this.video.className = "filtering"; this.video.style.width = "100%"; this.video.style.height = "100%"; this.video.style.objectFit = "contain"; this.videoContainer.appendChild(this.video); this.video.addEventListener(ppixiv.mobile? "dblclick":"click", this.togglePause); /\x2f True if we want to play if the window has focus. We always pause when backgrounded. let args = helpers.args.location; this.wantPlaying = !args.state.paused; /\x2f True if the user is seeking. We temporarily pause while seeking. This is separate /\x2f from this.wantPlaying so we stay paused after seeking if we were paused at the start. this.seeking = false; window.addEventListener("visibilitychange", this.refreshFocus.bind(this), { signal: this.shutdownSignal }); } async load() { /\x2f Show a static image while we're waiting for the video to load, like ViewerImages. /\x2f /\x2f Vview has two types of image for videos: thumbs (urls.small) and posters (urls.poster). /\x2f The thumbnail is a few seconds into the video to avoid completely black thumbs, so we /\x2f don't want to use it here. Only use the poster image, so it matches up with the start /\x2f of the video. /\x2f /\x2f Load partial media info if available to show the low-res preview quickly. This is a /\x2f simpler version of what ViewerImages does. let local = helpers.mediaId.isLocal(this.mediaId); let partialMediaInfo = await ppixiv.mediaCache.getMediaInfoSync(this.mediaId, { full: false }); if(partialMediaInfo) { if(local) this._createPreviewImage(partialMediaInfo.mangaPages[0].urls.poster, null); else this._createPreviewImage(partialMediaInfo.previewUrls[0], null); /\x2f Fire this.ready when the preview finishes loading. helpers.other.waitForImageLoad(this.previewImage).then(() => this.ready.accept(true)); } else { this.ready.accept(true); } /\x2f Load full data. let { slideshow=false, onnextimage=null } = this.options; let loadSentinel = await super.load(this.mediaId, { slideshow, onnextimage: () => onnextimage(this), }); if(loadSentinel !== this._loadSentinel) return; /\x2f This can be used to abort ZipImagePlayer's download. this.abortController = new AbortController; let source = null; if(local) { /\x2f The local API returns a separate path for these, since it doesn't have /\x2f illust_data.ugoiraMetadata. source = this.mediaInfo.mangaPages[0].urls.mjpeg_zip; } else { source = this.mediaInfo.ugoiraMetadata.originalSrc; } /\x2f Create the player. this.player = new ZipImagePlayer({ /\x2f This is only used for Pixiv animations, not local ones. metadata: this.mediaInfo.isLocal? null:this.mediaInfo.ugoiraMetadata, autoStart: false, source: source, local: local, mime_type: this.mediaInfo.isLocal? null: this.mediaInfo.ugoiraMetadata?.mime_type, signal: this.abortController.signal, autosize: true, canvas: this.video, loop: !slideshow, progress: this.progress, onfinished: () => onnextimage(this), }); this.player.videoInterface.addEventListener("timeupdate", this.ontimeupdate, { signal: this.abortController.signal }); this.videoUi.videoChanged({player: this, video: this.player.videoInterface}); this.refreshFocus(); } shutdown() { super.shutdown(); /\x2f Cancel the player's download and remove event listeners. if(this.abortController) { this.abortController.abort(); this.abortController = null; } /\x2f Send a finished progress callback if we were still loading. this.progress(null); this.video.hidden = true; if(this.player) { this.player.pause(); this.player = null; } if(this.previewImage) { this.previewImage.remove(); this.previewImage = null; } } async _createPreviewImage(url) { if(this.previewImage) { this.previewImage.remove(); this.previewImage = null; } /\x2f Create an image to display the static image while we load. /\x2f /\x2f Like static image viewing, load the thumbnail, then the main image on top, since /\x2f the thumbnail will often be visible immediately. let img = document.createElement("img"); img.classList.add("low-res-preview"); img.style.position = "absolute"; img.style.width = "100%"; img.style.height = "100%"; img.style.objectFit = "contain"; img.src = url ?? helpers.other.blankImage; this.videoContainer.appendChild(img); this.previewImage = img; /\x2f Allow clicking the previews too, so if you click to pause the video before it has enough /\x2f data to start playing, it'll still toggle to paused. img.addEventListener(ppixiv.mobile? "dblclick":"click", this.togglePause); } set active(active) { super.active = active; /\x2f Refresh playback, since we pause while the viewer isn't visible. this.refreshFocus(); } progress = (available) => { available ??= 1; this.setSeekBar({available}); } /\x2f Once we draw a frame, hide the preview and show the canvas. This avoids /\x2f flicker when the first frame is drawn. ontimeupdate = () => { if(this.previewImage) this.previewImage.hidden = true; this.video.hidden = false; this.updateSeekBar(); } updateSeekBar() { /\x2f Update the seek bar. let currentTime = this.player.getCurrentFrameTime(); let duration = this.player.getSeekableDuration(); this.setSeekBar({currentTime, duration}); } /\x2f This is sent manually by the UI handler so we can control focus better. onkeydown = (e) => { if(e.code >= "Digit1" && e.code <= "Digit9") { /\x2f 5 sets the speed to default, 1234 slow the video down, and 6789 speed it up. e.stopPropagation(); e.preventDefault(); if(!this.player) return; let speed; switch(e.code) { case "Digit1": speed = 0.10; break; case "Digit2": speed = 0.25; break; case "Digit3": speed = 0.50; break; case "Digit4": speed = 0.75; break; case "Digit5": speed = 1.00; break; case "Digit6": speed = 1.25; break; case "Digit7": speed = 1.50; break; case "Digit8": speed = 1.75; break; case "Digit9": speed = 2.00; break; } this.player.setSpeed(speed); return; } switch(e.code) { case "Space": e.stopPropagation(); e.preventDefault(); this.setWantPlaying(!this.wantPlaying); return; case "Home": e.stopPropagation(); e.preventDefault(); if(!this.player) return; this.player.rewind(); return; case "End": e.stopPropagation(); e.preventDefault(); if(!this.player) return; this.pause(); this.player.setCurrentFrame(this.player.getFrameCount() - 1); return; case "KeyQ": case "KeyW": e.stopPropagation(); e.preventDefault(); if(!this.player) return; this.pause(); let currentFrame = this.player.getCurrentFrame(); let next = e.code == "KeyW"; let newFrame = currentFrame + (next?+1:-1); this.player.setCurrentFrame(newFrame); return; } } play() { this.setWantPlaying(true); } pause() { this.setWantPlaying(false); } /\x2f Set whether the user wants the video to be playing or paused. setWantPlaying(value) { if(this.wantPlaying != value) { /\x2f Store the play/pause state in history, so if we navigate out and back in while /\x2f paused, we'll stay paused. let args = helpers.args.location; args.state.paused = !value; helpers.navigate(args, { addToHistory: false, cause: "updating-video-pause" }); this.wantPlaying = value; } this.refreshFocus(); } refreshFocus() { super.refreshFocus(); if(this.player == null) return; let active = this.wantPlaying && !this.seeking && !window.document.hidden && this._active; if(active) this.player.play(); else this.player.pause(); }; /\x2f This is called when the user interacts with the seek bar. seekCallback(pause, seconds) { super.seekCallback(pause, seconds); this.refreshFocus(); if(seconds != null) this.player.setCurrentFrameTime(seconds); }; } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/viewer/video/viewer-ugoira.js `), "/vview/viewer/video/viewer-video-base.js": loadBlob("application/javascript", `import Viewer from '/vview/viewer/viewer.js'; import VideoUI from '/vview/viewer/video/video-ui.js'; import DragHandler from '/vview/misc/drag-handler.js'; import SeekBar from '/vview/viewer/video/seek-bar.js'; import { helpers } from '/vview/misc/helpers.js'; export default class ViewerVideoBase extends Viewer { constructor({...options}) { super({...options, template: \`
\`}); this.videoContainer = this.root.querySelector(".video-container"); /\x2f Create the video UI. this.videoUi = new VideoUI({ container: this.root.querySelector(".video-ui-container"), }); this.videoUi.seekBar.setCurrentTime(0); this.videoUi.seekBar.setCallback(this.seekCallback.bind(this)); if(ppixiv.mobile) { /\x2f This seek bar is used for mobile seeking. It's placed at the top of the screen, so /\x2f it's not obscured by the user's hand, and drags with a DragHandler similar to TouchScroller's /\x2f dragging. The seek bar itself doesn't trigger seeks here. this.topSeekBar = new SeekBar({ container: this.root.querySelector(".top-seek-bar"), }); this.seekDragger = new DragHandler({ name: "seek-dragger", element: this.root, deferDelayMs: 30, ...this._signal, ondragstart: () => { this.seekCallback(true, null); this.dragRemainder = 0; helpers.html.setClass(this.topSeekBar.root, "dragging", true); return true; }, ondrag: ({movementX}) => { let fraction = movementX / Math.min(window.innerWidth, window.innerHeight); let currentTime = this._currentTime + this.dragRemainder; let position = currentTime / this._duration; position += fraction; position = helpers.math.clamp(position, 0, 1); let newPosition = position * this._duration; this.seekCallback(true, newPosition); /\x2f The video player may round the position. See how far from the requested position /\x2f we ended up on, and apply it to the next drag, so drag inputs smaller than a frame /\x2f aren't lost. this.dragRemainder = newPosition - this._currentTime; }, ondragend: () => { helpers.html.setClass(this.topSeekBar.root, "dragging", false); this.seekCallback(false, null); }, }); } } async load() { let loadSentinel = this._loadSentinel = new Object(); this.mediaInfo = await ppixiv.mediaCache.getMediaInfo(this.mediaId); return loadSentinel; } shutdown() { this.mediaInfo = null; /\x2f If this.load() is running, cancel it. this._loadSentinel = null; this.video.remove(); this.videoUi.seekBar.setCallback(null); super.shutdown(); } refreshFocus() { } togglePause = (e) => { this.setWantPlaying(!this.wantPlaying); this.refreshFocus(); } /\x2f This is called when the user interacts with the seek bar. seekCallback(pause, seconds) { this.seeking = pause; } setSeekBar({currentTime=null, duration=null, available=null}={}) { if(currentTime != null) this._currentTime = currentTime; if(duration != null) this._duration = duration; /\x2f If the seekable range changes during a drag, discard dragRemainder so we don't /\x2f snap into the newly loaded area on the next pointer movement. if(available != null) this.dragRemainder = null; for(let bar of [this.videoUi.seekBar, this.topSeekBar]) { if(bar == null) continue; if(currentTime != null) bar.setCurrentTime(currentTime); if(duration != null) bar.setDuration(duration); if(available != null) bar.setLoaded(available); } } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/viewer/video/viewer-video-base.js `), "/vview/viewer/video/viewer-video.js": loadBlob("application/javascript", `import ViewerVideoBase from '/vview/viewer/video/viewer-video-base.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f A player for video files. /\x2f /\x2f This is only used for local files, since Pixiv doesn't have any video support. /\x2f See ViewerUgoira for Pixiv's jank animation format. /\x2f /\x2f We don't show buffering. This is only used for viewing local files. export default class ViewerVideo extends ViewerVideoBase { constructor({...options}) { super({...options}); /\x2f Create a canvas to render into. this.video = document.createElement("video"); this.video.loop = true; this.video.controls = false; this.video.preload = "auto"; this.video.playsInline = true; /\x2f prevents iOS taking over the video on long press this.video.volume = ppixiv.settings.get("volume"); this.video.muted = ppixiv.settings.get("mute"); /\x2f Set the video inert to work around an iOS bug: after PIP is activated on a video and /\x2f then deactivated, the shadow controls for the "this video is playing in picture in /\x2f picture" still exist and continue to cancel pointer events forever. We don't use inputs /\x2f directly on the video, so we can set it inert to prevent this from happening. this.video.inert = true; /\x2f Store changes to volume. this.video.addEventListener("volumechange", (e) => { ppixiv.settings.set("volume", this.video.volume); ppixiv.settings.set("mute", this.video.muted); }); this.video.autoplay = true; this.video.className = "filtering"; this.video.style.width = "100%"; this.video.style.height = "100%"; this.video.style.display = "block"; this.videoContainer.appendChild(this.video); this.video.addEventListener("timeupdate", () => this.updateSeekBar()); this.video.addEventListener("progress", () => this.updateSeekBar()); /\x2f Clicking on mobile shows the menu, so use dblclick for pause. this.videoContainer.addEventListener(ppixiv.mobile? "dblclick":"click", this.togglePause); /\x2f In case we start PIP without playing first, switch the poster when PIP starts. this.video.addEventListener("enterpictureinpicture", (e) => { this._switchPosterToThumb(); }); /\x2f True if we want to play if the window has focus. We always pause when backgrounded. let args = helpers.args.location; this.wantPlaying = !args.state.paused; /\x2f True if the user is seeking. We temporarily pause while seeking. This is separate /\x2f from this.wantPlaying so we stay paused after seeking if we were paused at the start. this.seeking = false; } async load(mediaId, { slideshow=false, onnextimage=() => { }, }={}) { await super.load(mediaId, { slideshow, onnextimage }); /\x2f Remove the old source, if any, and create a new one. if(this.source) this.source.remove(); this.source = document.createElement("source"); /\x2f Don't loop in slideshow. this.video.loop = !slideshow; this.video.onended = () => { onnextimage(this); }; this.video.appendChild(this.source); /\x2f Set the video URLs. this.video.poster = this.mediaInfo.mangaPages[0].urls.poster; this.source.src = this.mediaInfo.mangaPages[0].urls.original; this.updateSeekBar(); /\x2f Sometimes mysteriously needing a separate load() call isn't isn't a sign of /\x2f good HTML element design. Everything else just updates after you change it, /\x2f how did this go wrong? this.video.load(); /\x2f Tell the video UI about the video. this.videoUi.videoChanged({player: this, video: this.video}); /\x2f We want to wait until something is displayed before firing this.ready, but /\x2f HTMLVideoElement doesn't give an event for that, and there's no event at /\x2f all to tell when the poster is loaded. Decode the poster separately and /\x2f hope it completes at the same time as the video doing it, and also continue /\x2f on canplay. let img = document.createElement("img"); img.src = this.video.poster; let decode = img.decode(); let canplay = helpers.other.waitForEvent(this.video, "loadeddata"); /\x2f Wait for at least one to complete. await Promise.any([canplay, decode]); this.ready.accept(true); this.refreshFocus(); } shutdown() { super.shutdown(); if(this.source) { this.source.remove(); this.source = null; } if(this.player) { this.player.pause(); this.player = null; } } set active(active) { super.active = active; /\x2f Refresh playback, since we pause while the viewer isn't visible. this.refreshFocus(); } /\x2f Replace the poster with the thumbnail if we enter PIP. Chrome displays the poster /\x2f in the main window while PIP is active, and the thumbnail is better for that. It's /\x2f low res, but Chrome blurs this image anyway. _switchPosterToThumb() { if(this.mediaInfo != null) this.video.poster = this.mediaInfo.mangaPages[0].urls.small; } updateSeekBar() { /\x2f Update the seek bar. let currentTime = isNaN(this.video.currentTime)? 0:this.video.currentTime; let duration = isNaN(this.video.duration)? 1:this.video.duration; this.setSeekBar({currentTime, duration}); } toggleMute() { this.video.muted = !this.video.muted; } /\x2f This is sent manually by the UI handler so we can control focus better. onkeydown = (e) => { if(this.video == null) return; if(e.code >= "Digit1" && e.code <= "Digit9") { /\x2f 5 sets the speed to default, 1234 slow the video down, and 6789 speed it up. e.stopPropagation(); e.preventDefault(); if(!this.video) return; let speed; switch(e.code) { case "Digit1": speed = 0.10; break; case "Digit2": speed = 0.25; break; case "Digit3": speed = 0.50; break; case "Digit4": speed = 0.75; break; case "Digit5": speed = 1.00; break; case "Digit6": speed = 1.25; break; case "Digit7": speed = 1.50; break; case "Digit8": speed = 1.75; break; case "Digit9": speed = 2.00; break; } this.video.playbackRate = speed; return; } switch(e.code) { case "KeyM": this.toggleMute(); break; case "Space": e.stopPropagation(); e.preventDefault(); this.setWantPlaying(!this.wantPlaying); return; case "Home": e.stopPropagation(); e.preventDefault(); if(!this.video) return; this.video.currentTime = 0; return; case "End": e.stopPropagation(); e.preventDefault(); if(!this.video) return; this.pause(); /\x2f This isn't completely reliable. If we set the time to the very end, the video loops /\x2f immediately and we go to the beginning. If we set it to duration - 0.000001, it gets /\x2f rounded and loops anyway, and if we set it to duration - 1 we end up too far. It might /\x2f depend on the video framerate and need to be set to duration - 1 frame, but the HTML video /\x2f API is painfully incomplete and doesn't include any sort of frame info or frame stepping. /\x2f Try using a small-but-not-too-small value. this.video.currentTime = this.video.duration - 0.001; return; } } play() { this.setWantPlaying(true); } pause() { this.setWantPlaying(false); } /\x2f Set whether the user wants the video to be playing or paused. setWantPlaying(value) { if(this.wantPlaying != value) { /\x2f Store the play/pause state in history, so if we navigate out and back in while /\x2f paused, we'll stay paused. let args = helpers.args.location; args.state.paused = !value; helpers.navigate(args, { addToHistory: false, cause: "updating-video-pause" }); this.wantPlaying = value; } this.refreshFocus(); } refreshFocus() { super.refreshFocus(); if(this.source == null) return; let active = this.wantPlaying && !this.seeking && this._active; if(active) this.video.play(); else this.video.pause(); }; /\x2f This is called when the user interacts with the seek bar. seekCallback(pause, seconds) { super.seekCallback(pause, seconds); this.refreshFocus(); if(seconds != null) { this.video.currentTime = seconds; this.updateSeekBar(); this.videoUi.timeChanged(); } }; } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/viewer/video/viewer-video.js `), "/vview/widgets/bookmark-tag-list.js": loadBlob("application/javascript", `import Actor from '/vview/actors/actor.js'; import Actions from '/vview/misc/actions.js'; import RecentBookmarkTags from '/vview/misc/recent-bookmark-tags.js'; import { IllustWidget } from '/vview/widgets/illust-widgets.js'; import { DropdownBoxOpener } from '/vview/widgets/dropdown.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f Widget for editing bookmark tags. export class BookmarkTagListWidget extends IllustWidget { get neededData() { return "mediaId"; } constructor({...options}) { super({...options, template: \`
\`}); this.displayingMediaId = null; this.root.addEventListener("click", this._clickedBookmarkTag, true); this.deactivated = false; ppixiv.settings.addEventListener("recent-bookmark-tags", this.refresh.bind(this)); } /\x2f Deactivate this widget. We won't refresh or make any bookmark changes after being /\x2f deactivated. This is used by the bookmark button widget. The widget will become /\x2f active again the next time it's displayed. deactivate() { this.deactivated = true; } shutdown() { /\x2f If we weren't hidden before being shut down, set ourselves hidden so we save any /\x2f changes. this.visible = false; super.shutdown(); } /\x2f Return an array of tags selected in the tag dropdown. get selectedTags() { let tagList = []; let bookmarkTags = this.root; for(let entry of bookmarkTags.querySelectorAll(".popup-bookmark-tag-entry")) { if(!entry.classList.contains("selected")) continue; tagList.push(entry.dataset.tag); } return tagList; } /\x2f Override setting mediaId to save tags when we're closed. Otherwise, mediaId will already /\x2f be cleared when we close and we won't be able to save. setMediaId(mediaId) { /\x2f If we're hiding and were previously visible, save changes. if(mediaId == null) this.saveCurrentTags(); super.setMediaId(mediaId); } async visibilityChanged() { if(this.visible) { /\x2f If we were deactivated, reactivate when we become visible again. if(this.deactivated) console.info("reactivating tag list widget"); this.deactivated = false; /\x2f We only load existing bookmark tags when the tag list is open, so refresh. await this.refresh(); } else { /\x2f Save any selected tags when the dropdown is closed. this.saveCurrentTags(); /\x2f Clear the tag list when the menu closes, so it's clean on the next refresh. this._clearTagList(); this.displayingMediaId = null; } /\x2f The base class will refresh, so call this after calling saveCurrentTags(). super.visibilityChanged(); } _clearTagList() { /\x2f Make a copy of children when iterating, since it doesn't handle items being deleted /\x2f while iterating cleanly. let bookmarkTags = this.root.querySelector(".tag-list"); for(let element of [...bookmarkTags.children]) { if(element.classList.contains("dynamic") || element.classList.contains("loading")) element.remove(); } } async refreshInternal({ mediaId }) { if(this.deactivated) return; /\x2f If we're hidden, leave the list empty. if(!this.visible) mediaId = null; /\x2f If we're refreshing the same illust that's already refreshed, store which tags were selected /\x2f before we clear the list. let oldSelectedTags = this.displayingMediaId == mediaId? this.selectedTags:[]; let bookmarkTags = this.root.querySelector(".tag-list"); /\x2f Make sure we don't show tags from a previous image. if(mediaId != this.displayingMediaId) { this._clearTagList(); this.displayingMediaId = null; } if(mediaId == null) return; /\x2f If the ID is changing (we're not refreshing in-place), the list will be empty while we /\x2f load info, so create a temporary "loading" entry. if(mediaId != this.displayingMediaId) { let entry = document.createElement("span"); entry.classList.add("loading"); bookmarkTags.appendChild(entry); entry.innerText = "Loading..."; } /\x2f If the tag list is open, populate bookmark details to get bookmark tags. /\x2f If the image isn't bookmarked this won't do anything. let activeTags = await ppixiv.extraCache.loadBookmarkDetails(mediaId); /\x2f Remember which illustration's bookmark tags are actually loaded. this.displayingMediaId = mediaId; /\x2f Remove elements again, in case another refresh happened while we were async /\x2f and to remove the loading entry. this._clearTagList(); /\x2f If we're refreshing the list while it's open, make sure that any tags the user /\x2f selected are still in the list, even if they were removed by the refresh. Put /\x2f them in activeTags, so they'll be marked as active. for(let tag of oldSelectedTags) { if(activeTags.indexOf(tag) == -1) activeTags.push(tag); } let shownTags = []; let recentBookmarkTags = [...RecentBookmarkTags.getRecentBookmarkTags()]; /\x2f copy for(let tag of recentBookmarkTags) if(shownTags.indexOf(tag) == -1) shownTags.push(tag); /\x2f Add any tags that are on the bookmark but not in recent tags. for(let tag of activeTags) if(shownTags.indexOf(tag) == -1) shownTags.push(tag); shownTags.sort((lhs, rhs) => lhs.toLowerCase().localeCompare(rhs.toLowerCase())); let createEntry = (tag, { classes=[], icon }={}) => { let entry = this.createTemplate({name: "tag-entry", html: \`
\`}); for(let cls of classes) entry.classList.add(cls); entry.querySelector(".tag-name").innerText = tag; if(icon) entry.querySelector(".tag-name").insertAdjacentElement("afterbegin", icon); bookmarkTags.appendChild(entry); return entry; } let addButton = createEntry("Add", { icon: helpers.createIcon("add", { asElement: true }), classes: ["add-button"], }); addButton.addEventListener("click", () => Actions.addNewBookmarkTag(this._mediaId)); for(let tag of shownTags) { let entry = createEntry(tag, { classes: ["tag-toggle"], /\x2f icon: helpers.createIcon("ppixiv:tag", { asElement: true }), }); entry.dataset.tag = tag; let active = activeTags.indexOf(tag) != -1; helpers.html.setClass(entry, "selected", active); } let syncButton = createEntry("Refresh", { icon: helpers.createIcon("refresh", { asElement: true }), classes: ["refresh-button"], }); syncButton.addEventListener("click", async (e) => { let bookmarkTags = await Actions.loadRecentBookmarkTags(); RecentBookmarkTags.setRecentBookmarkTags(bookmarkTags); this.refreshInternal({mediaId: this.mediaId}); }); } /\x2f Save the selected bookmark tags to the current illust. async saveCurrentTags() { if(this.deactivated) return; /\x2f Store the ID and tag list we're saving, since they can change when we await. let mediaId = this._mediaId; let newTags = this.selectedTags; if(mediaId == null) return; /\x2f Only save tags if we're refreshed to the current illust ID, to make sure we don't save /\x2f incorrectly if we're currently waiting for the async refresh. if(mediaId != this.displayingMediaId) return; /\x2f Get the tags currently on the bookmark to compare. let oldTags = await ppixiv.extraCache.loadBookmarkDetails(mediaId); let equal = newTags.length == oldTags.length; for(let tag of newTags) { if(oldTags.indexOf(tag) == -1) equal = false; } /\x2f If the selected tags haven't changed, we're done. if(equal) return; /\x2f Save the tags. If the image wasn't bookmarked, this will create a public bookmark. console.log(\`Tag list closing and tags have changed: "\${oldTags.join(",")}" -> "\${newTags.join(",")}"\`); await Actions.bookmarkAdd(this._mediaId, { tags: newTags, }); } /\x2f Toggle tags on click. We don't save changes until we're closed. _clickedBookmarkTag = async(e) => { if(this.deactivated) return; let a = e.target.closest(".tag-toggle"); if(a == null) return; e.preventDefault(); e.stopPropagation(); /\x2f Toggle this tag. Don't actually save it immediately, so if we make multiple /\x2f changes we don't spam requests. helpers.html.setClass(a, "selected", !a.classList.contains("selected")); } } /\x2f A bookmark tag list in a dropdown. /\x2f /\x2f The base class is a simple widget. This subclass handles some of the trickier /\x2f bits around closing the dropdown correctly, and tells any bookmark buttons about /\x2f itself. class BookmarkTagListDropdownWidget extends BookmarkTagListWidget { constructor({ mediaId, bookmarkButtons, ...options }) { super({ classes: ["popup-bookmark-tag-dropdown"], ...options }); this.root.classList.add("popup-bookmark-tag-dropdown"); this.bookmarkButtons = bookmarkButtons; this.setMediaId(mediaId); /\x2f Let the bookmark buttons know about this bookmark tag dropdown, and remove it when /\x2f it's closed. for(let bookmarkButton of this.bookmarkButtons) bookmarkButton.bookmarkTagListWidget = this; } async refreshInternal({ mediaId }) { /\x2f Make sure the dropdown is hidden if we have no image. if(mediaId == null) this.visible = false; await super.refreshInternal({ mediaId }); } /\x2f Hide if our tree becomes hidden. visibilityChanged() { super.visibilityChanged(); if(!this.visibleRecursively) this.visible = false; } shutdown() { super.shutdown(); for(let bookmarkButton of this.bookmarkButtons) { if(bookmarkButton.bookmarkTagListWidget == this) bookmarkButton.bookmarkTagListWidget = null; } } } /\x2f This opens the bookmark tag dropdown when a button is pressed. export class BookmarkTagDropdownOpener extends Actor { constructor({ /\x2f The bookmark tag button which opens the dropdown. bookmarkTagsButton, /\x2f The associated bookmark button widgets, if any. bookmarkButtons, onvisibilitychanged, ...options }) { super({...options}); this.bookmarkTagsButton = bookmarkTagsButton; this.bookmarkButtons = bookmarkButtons; this._mediaId = null; /\x2f Create an opener to actually create the dropdown. this._opener = new DropdownBoxOpener({ button: bookmarkTagsButton, onvisibilitychanged, createDropdown: this._createBox, /\x2f If we have bookmark buttons, don't close for clicks inside them. We need the /\x2f bookmark button to handle the click first, then it'll close us. shouldCloseForClick: (e) => { for(let button of this.bookmarkButtons) { if(helpers.html.isAbove(button.root, e.target)) return false; } return true; }, }); bookmarkTagsButton.addEventListener("click", (e) => { this._opener.visible = !this._opener.visible; }); for(let button of this.bookmarkButtons) { button.addEventListener("bookmarkedited", () => { this._opener.visible = false; }, this._signal); } } setMediaId(mediaId) { if(this._mediaId == mediaId) return; this._mediaId = mediaId; helpers.html.setClass(this.bookmarkTagsButton, "enabled", mediaId != null); /\x2f Hide the dropdown if the image changes while it's open. this._opener.visible = false; } _createBox = ({...options}) => { if(this._mediaId == null) return; return new BookmarkTagListDropdownWidget({ ...options, parent: this, mediaId: this._mediaId, bookmarkButtons: this.bookmarkButtons, }); } set visible(value) { this._opener.visible = value; } get visible() { return this._opener.visible; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/bookmark-tag-list.js `), "/vview/widgets/click-outside-listener.js": loadBlob("application/javascript", `/\x2f Call a callback on any click not inside a list of nodes. /\x2f /\x2f This is used to close dropdown menus. import Actor from '/vview/actors/actor.js'; import Widget from '/vview/widgets/widget.js'; import PointerListener from '/vview/actors/pointer-listener.js'; import { helpers } from '/vview/misc/helpers.js'; export default class ClickOutsideListener extends Actor { constructor(nodeList, callback) { super({}); this.nodeList = nodeList; this.callback = callback; new PointerListener({ element: document.documentElement, buttonMask: 0xFFFF, callback: this.windowPointerdown, ...this._signal, }); } /\x2f Return true if node is below any node in nodeList. _isNodeInList(node) { for(let ancestor of this.nodeList) { /\x2f nodeList can contain both DOM nodes and widgets (actors). If this is a widget, /\x2f see if the node is within the widget's tree. if(ancestor instanceof Actor) { for(let rootNode of ancestor.getRoots()) if(helpers.html.isAbove(rootNode, node)) return true; } else { /\x2f node is just a DOM node. if(helpers.html.isAbove(ancestor, node)) return true; } } return false; } windowPointerdown = (e) => { if(!e.pressed) return; /\x2f Close the popup if anything outside the dropdown is clicked. Don't /\x2f prevent the click event, so the click still happens. /\x2f /\x2f If this is a click inside the box or our button, ignore it. if(this._isNodeInList(e.target)) return; /\x2f We don't cancel this event, but set a property on it to let IsolatedTapHandler /\x2f know this press shouldn't be treated as an isolated tap. e.partiallyHandled = true; this.callback(e.target, {event: e}); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/click-outside-listener.js `), "/vview/widgets/dialog.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; import WidgetDragger from '/vview/actors/widget-dragger.js'; import { helpers, OpenWidgets } from '/vview/misc/helpers.js'; export default class DialogWidget extends Widget { /\x2f The stack of dialogs currently open: static activeDialogs = []; static get topDialog() { return this.activeDialogs[this.activeDialogs.length-1]; } static _updateBlockTouchScrolling() { if(!window.ppixiv?.ios) return; /\x2f This is really annoying. No matter how much you shout at iOS to not scroll the document, /\x2f whether with overflow: hidden, inert or pointer-events: none, it ignores you and scrolls /\x2f the document underneath the dialog. The only way I've found to prevent this is by cancelling /\x2f touchmove (touchstart doesn't work). /\x2f /\x2f Note that even touch-action: none doesn't work. It seems to interpret it as "don't let touches /\x2f on this element scroll" instead of "this element shouldn't scroll with touch": touches on child /\x2f elements will still propagate up and scroll the body, which is useless. /\x2f /\x2f This hack partially works, but the body still scrolls when it shouldn't if an area is dragged /\x2f which is set to overflow: auto or overflow: scroll but doesn't actually scroll. We can't tell /\x2f that it isn't scrolling, and iOS seems to blindly propagate any touch on a potentially-scrollable /\x2f element up to the nearest scrollable one. if(DialogWidget.activeDialogs.length == 0) { if(this._removeTouchScrollerEvents != null) { this._removeTouchScrollerEvents.abort(); this._removeTouchScrollerEvents = null; } return; } /\x2f At least one dialog is open. Start listening to touchmove if we're not already. if(this._removeTouchScrollerEvents) return; this._removeTouchScrollerEvents = new AbortController(); window.addEventListener("touchmove", (e) => { /\x2f Block this movement if it's not inside the topmost open dialog. let topDialog = DialogWidget.topDialog; let dialog = topDialog.root.querySelector(".dialog"); if(!helpers.html.isAbove(dialog, e.target)) e.preventDefault(); }, { capture: true, passive: false, signal: this._removeTouchScrollerEvents.signal }); } constructor({ classes=null, container=null, /\x2f "normal" is used for larger dialogs, like settings. /\x2f "small" is used for smaller popups like text entry. dialogType="normal", dialogClass=null, /\x2f The header text: header=null, /\x2f Most dialogs have a close button and allow the user to navigate away. To /\x2f disable this and control visibility directly, set this to false. allowClose=true, /\x2f Most dialogs that can be closed have a close button in the corner. If this is /\x2f false we'll hide that button, but you can still exit by clicking the background. /\x2f This is used for very simple dialogs. showCloseButton=true, /\x2f If false, this dialog may be large, like settings, and we'll display it in fullscreen /\x2f on small screens. If true, weit's a small dialog like a confirmation prompt, and we'll /\x2f always show it as a floating dialog. The default is true if dialogType == "small", /\x2f otherwise false. small=null, /\x2f If true, the close button shows a back icon instead of an X. backIcon=false, template, ...options }) { if(small == null) small = dialogType == "small"; /\x2f Most dialogs are added to the body element. if(container == null) container = document.body; console.assert(dialogType == "normal" || dialogType == "small"); if(dialogClass == null) dialogClass = dialogType == "normal"? "dialog-normal":"dialog-small"; let closeIcon = backIcon? "arrow_back_ios_new":"close"; super({ container, template: \`
\${ helpers.createIcon(closeIcon) }
\${ template }
\`, ...options, }); /\x2f Always hide the close button on mobile. if(window.ppixiv?.mobile) showCloseButton = false; /\x2f Dialogs are always used once and not reused, so they should never be created invisible. if(!this.visible) throw new Error("Dialog shouldn't be hidden"); this.small = small; helpers.html.setClass(this.root, "small", this.small); helpers.html.setClass(this.root, "large", !this.small); this.dragToExit = true; this.refreshDialogMode(); window.addEventListener("resize", () => this.refreshDialogMode(), this._signal); /\x2f Create the dragger that will control animations. Animations are only used on mobile. if(window.ppixiv?.mobile) { this._dialogDragger = new WidgetDragger({ parent: this, name: "close-dialog", nodes: this.root, dragNode: this.root, visible: false, duration: 200, animatedProperty: "--dialog-visible", direction: "up", /\x2f up opens, down closes onbeforeshown: () => this.callVisibilityChanged(), onafterhidden: () => this.callVisibilityChanged(), confirmDrag: ({event}) => { /\x2f This is still used for transitions even if it's not used for drags, but only /\x2f allow dragging if dragToExit is true. if(!this.dragToExit) return false; /\x2f If this dialog closes by dragging down, only begin the drag if the scroller /\x2f is already scrolled to the top. Otherwise, allow the scroller to scroll. /\x2f /\x2f On iOS, don't scroll if we're overscrolled past the top, to roughly match the /\x2f native behavior. We use a small threshold here so we do start if we're just /\x2f slightly past it (this also roughly matches native). /\x2f /\x2f If the drag touch is outside the scroller, such as on the title, always allow /\x2f the drag to start. let scroll = this.querySelector(".scroll"); if(helpers.html.isAbove(scroll, event.target)) { /\x2f We're overscrolled if scrollTop is negative. Give a bit of leeway, so /\x2f we can scroll to hide even if there's a bit of scrolling. if(scroll.scrollTop > 0 || scroll.scrollTop < -25) return false; } return true; }, /\x2f The drag size and the transition should have the same distance, so drags are synchronized. /\x2f The drag distance is controlled by the dialog transform and transforms by 100% of the height, /\x2f which this matches. The two can be out of sync briefly if the dialog refreshes and changes /\x2f its contents. size: () => { return this.querySelector(".dialog").getBoundingClientRect().height; }, /\x2f Set dragging while dragging the dialog to disable the scroller. onactive: () => this.root.classList.add("dragging-dialog"), oninactive: () => this.root.classList.remove("dragging-dialog"), }); } /\x2f If we're not the first dialog on the stack, make the previous dialog inert, so it'll ignore inputs. let oldTopDialog = DialogWidget.topDialog; if(oldTopDialog) oldTopDialog.root.inert = true; /\x2f Add ourself to the stack. DialogWidget.activeDialogs.push(this); /\x2f Register ourself as an important visible widget, so the slideshow won't move on /\x2f while we're open. OpenWidgets.singleton.set(this, true); if(!header && !showCloseButton) this.root.querySelector(".header").hidden = true; this.allowClose = allowClose; this.root.querySelector(".close-button").hidden = !allowClose || !showCloseButton; this.header = header; window.addEventListener("keydown", this._onkeypress.bind(this), { signal: this.shutdownSignal }); if(this.allowClose) { /\x2f Close if the container is clicked, but not if something inside the container is clicked. this.root.addEventListener("click", (e) => { if(e.target != this.root) return; this.visible = false; }); let closeButton = this.root.querySelector(".close-button"); if(closeButton) closeButton.addEventListener("click", (e) => { this.visible = false; }); /\x2f Hide if the top-level screen changes, so we close if the user exits the screen with browser /\x2f navigation but not if the viewed image is changing from something like the slideshow. Call /\x2f shutdown() directly instead of setting visible, since we don't want to trigger animations here. window.addEventListener("screenchanged", (e) => { this.shutdown(); }, { signal: this.shutdownSignal }); if(this._close_on_popstate) { /\x2f Hide on any state change. window.addEventListener("pp:popstate", (e) => { this.shutdown(); }, { signal: this.shutdownSignal }); } } DialogWidget._updateBlockTouchScrolling(); } afterInit() { /\x2f Show the dragger. Do this after the ctor so we aren't causing visibility callbacks /\x2f before the subclass is set up. if(this._dialogDragger) this._dialogDragger.show(); super.afterInit(); } /\x2f The subclass can override this to disable automatically closing on popstate. get _close_on_popstate() { return true; } set header(value) { this.root.querySelector(".header-text").textContent = value ?? ""; } refreshDialogMode() { helpers.html.setClass(this.root, "floating", !helpers.other.isPhone() || this.small); } visibilityChanged() { super.visibilityChanged(); /\x2f Remove the widget when it's hidden. If we're animating, we'll do this after transitionend. if(!this.actuallyVisible) this.shutdown(); } _onkeypress(e) { let idx = DialogWidget.activeDialogs.indexOf(this); if(idx == -1) { console.error("Widget isn't in activeDialogs during keypress:", this); return; } /\x2f Ignore keypresses if we're not the topmost dialog. if(idx != DialogWidget.activeDialogs.length-1) return; if(this._handleKeydown(e)) { e.preventDefault(); e.stopPropagation(); } } /\x2f This can be overridden by the implementation. _handleKeydown(e) { if(this.allowClose && e.key == "Escape") { this.visible = false; return true; } return false; } get actuallyVisible() { /\x2f If we have an animator, it determines whether we're visible. if(this._dialogDragger) return this._dialogDragger.visible; else return super.visible; } async applyVisibility() { if(this._dialogDragger == null || this._visible) { super.applyVisibility(); return; } /\x2f We're being hidden and we have an animation. Tell the dragger to run our hide /\x2f animation. We'll shut down when it finishes. Make this animation uninterruptible, /\x2f so it can't be interrupted by dragging. this._dialogDragger.hide({interruptible: false}); } /\x2f If a dragger animation is running, return its completion promise. visibilityChangePromise() { return this._dialogDragger?.finished; } /\x2f Calling shutdown() directly will remove the dialog immediately. To remove it and allow /\x2f animations to run, set visible to false, and the dialog will shut down when the animation /\x2f finishes. shutdown() { /\x2f Remove ourself from activeDialogs. let idx = DialogWidget.activeDialogs.indexOf(this); if(idx == -1) console.error("Widget isn't in activeDialogs when shutting down:", this); else DialogWidget.activeDialogs.splice(idx, 1); /\x2f Tell OpenWidgets that we're no longer open. OpenWidgets.singleton.set(this, false); DialogWidget._updateBlockTouchScrolling(); /\x2f If we were covering another dialog, unset inert on the previous dialog. let newTopDialog = DialogWidget.topDialog; if(newTopDialog) newTopDialog.root.inert = false; super.shutdown(); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/dialog.js `), "/vview/widgets/dropdown.js": loadBlob("application/javascript", `import Actor from '/vview/actors/actor.js'; import Widget from '/vview/widgets/widget.js'; import Dialog from '/vview/widgets/dialog.js'; import ClickOutsideListener from '/vview/widgets/click-outside-listener.js'; import { helpers, OpenWidgets } from '/vview/misc/helpers.js'; /\x2f A helper to display a dropdown aligned to another node. export class DropdownBoxOpener extends Actor { constructor({ button, /\x2f The dropdown will be closed on clicks outside of the dropdown unless this returns /\x2f false. shouldCloseForClick=(e) => true, /\x2f This is called when button is clicked and should return a widget to display. The /\x2f widget will be shut down when it's dismissed. createDropdown=null, onvisibilitychanged=() => { }, /\x2f If true (or a function that returns true), open the dropdown as a dialog instead. /\x2f /\x2f On mobile, dropdowns will always open as dialogs if they're inside another dialog. asDialog=() => false, /\x2f If true, clicking the button toggles the dropdown. clickToOpen=false, /\x2f If null, the widget containing the button is our parent. parent=null, ...options }) { /\x2f Find a parent widget above the button. parent ??= Widget.fromNode(button); super({ parent, ...options, }); this.button = button; this.shouldCloseForClick = shouldCloseForClick; this.onvisibilitychanged = onvisibilitychanged; this.createDropdown = createDropdown; if(asDialog instanceof Function) this.asDialog = asDialog; else this.asDialog = () => asDialog; this._dropdown = null; this._visible = false; /\x2f Refresh the position if the box width changes. Don't refresh on any ResizeObserver /\x2f call, since that'll recurse and end up refreshing constantly. this._boxWidth = 0; if(clickToOpen) this.button.addEventListener("click", (e) => this.visible = !this.visible, this._signal); } onwindowresize = (e) => { this._alignToButton(); }; /\x2f Hide if our tree becomes hidden. visibilityChanged() { super.visibilityChanged(); if(!this.visibleRecursively) this.visible = false; } get visible() { return this._visible; } /\x2f If the dropdown is open, return it. get dropdown() { return this._dropdown; } set visible(value) { if(this._visible == value) return; this._visible = value; /\x2f If we're inside ScreenSearch's top-ui-box container, set .force-open on that element /\x2f while we're open. This prevents it from being hidden while a dropdown inside it is /\x2f open. let topUiBox = this.parent.closest(".top-ui-box"); if(topUiBox) helpers.html.setClass(topUiBox, "force-open", value); /\x2f Register this as an open widget to pause slideshows. OpenWidgets.singleton.set(this, value); if(value) { let asDialog = this.asDialog(); if(window.ppixiv?.mobile) { /\x2f Always open dropdowns as dialogs if we're on mobile and inside another dialog. for(let node of this.ancestors()) { if(node instanceof Dialog) { console.log("Opening dropdown as a dialog because we're inside another dialog:", node); asDialog = true; break; } } } /\x2f Normally, the dropdown's container is the document so we can position it easily, and /\x2f we're its parent. If we're opening it in a dialog then the dialog is its container and /\x2f parent, and the dialog owns the dropdown. let container = document.body; let parent = this; this._dropdownDialog = null; if(asDialog) { parent = this._dropdownDialog = new Dialog({ parent: this, template: \`
\`, }); this._dropdownDialog.shutdownSignal.addEventListener("abort", (e) => { /\x2f Ignore this if it's from a previous dialog that we discarded. if(e.target != this._dropdownDialog?.shutdownSignal) return; console.log("Dialog dropdown closed"); /\x2f The dropdown shut itself down and the dropdown with it. Clear them so /\x2f we don't try to shut them down again. this._dropdownDialog = null; this._dropdown = null; this.visible = false; }); container = this._dropdownDialog.querySelector(".scroll"); } this._dropdown = this.createDropdown({ container, parent }); /\x2f Stop if no widget was created. if(this._dropdown == null) { this._visible = false; /\x2f If we created a dialog, remove it since we're not going to use it. if(this._dropdownDialog) { this._dropdownDialog.shutdown(); this._dropdownDialog = null; } return; } if(!asDialog) { this._dropdown.root.classList.add("dropdown-box"); this.listener = new ClickOutsideListener([this.button, this._dropdown], (target, {event}) => { if(!this.shouldCloseForClick(event)) return; this.visible = false; }); this._resizeObserver = new ResizeObserver(() => { if(this._boxWidth == this._dropdown.root.offsetWidth) return; this._boxWidth = this._dropdown.root.offsetWidth; this._alignToButton(); }); this._resizeObserver.observe(this._dropdown.root); /\x2f We manually position the dropdown, so we need to reposition them if /\x2f the window size changes. window.addEventListener("resize", this.onwindowresize, this._signal); this._alignToButton(); } if(this.closeOnClickInside) this._dropdown.root.addEventListener("click", this.boxClicked); } else { /\x2f If we have a dialog, tell the dialog to hide itself and discard it. It'll /\x2f shut the dropdown and itself down after any transitions complete. if(this._dropdownDialog) { this._dropdownDialog.visible = false; this._dropdownDialog = null; this._dropdown = null; return; } this._cleanup(); if(this._dropdown) { this._dropdown.shutdown(); this._dropdown = null; } } this.onvisibilitychanged(this); } _cleanup() { this.visible = false; if(this._resizeObserver) { this._resizeObserver.disconnect(); this._resizeObserver = null; } if(this.listener) { this.listener.shutdown(); this.listener = null; } window.removeEventListener("resize", this.onwindowresize); OpenWidgets.singleton.set(this, false); } _alignToButton() { if(!this.visible) return; /\x2f This isn't used when displaying as a dialog. if(this._dropdownDialog) return; /\x2f The amount of padding to leave relative to the button we're aligning to. let horizontalPadding = 4, verticalPadding = 8; /\x2f Figure out the z-index of the button we're positioning relative to, and put /\x2f ourselves over it. let buttonParent = this.button; this._dropdown.root.style.zIndex = 1; while(buttonParent) { let { zIndex } = getComputedStyle(buttonParent); if(zIndex != "auto") { zIndex = parseInt(zIndex); this._dropdown.root.style.zIndex = zIndex + 1; break; } buttonParent = buttonParent.offsetParent; } /\x2f Use getBoundingClientRect to figure out the position, since it works /\x2f correctly with CSS transforms. Figure out how far off we are and move /\x2f by that amount. This works regardless of what our relative position is. /\x2flet {left: box_x, top: box_y} = this._dropdown.root.getBoundingClientRect(document.body); let {left: buttonX, top: buttonY, height: boxHeight} = this.button.getBoundingClientRect(); /\x2f Align to the left of the button. Nudge left slightly for padding. let x = buttonX - horizontalPadding; /\x2f If the right edge of the box is offscreen, push the box left. Leave a bit of /\x2f padding on desktop, so the dropdown isn't flush with the edge of the window. /\x2f On mobile, allow the box to be flush with the edge. let padding = window.ppixiv?.mobile? 0:4; let rightEdge = x + this._boxWidth; x -= Math.max(rightEdge - (window.innerWidth - padding), 0); /\x2f Don't push the left edge past the left edge of the screen. x = Math.max(x, 0); let y = buttonY; this._dropdown.root.style.left = \`\${x}px\`; /\x2f Put the dropdown below the button if we're on the top half of the screen, otherwise /\x2f put it above. if(y < window.innerHeight / 2) { /\x2f Align to the bottom of the button, adding a bit of padding. y += boxHeight + verticalPadding; this._dropdown.root.style.top = \`\${y}px\`; this._dropdown.root.style.bottom = ""; /\x2f Set the box's maxHeight so it doesn't cross the bottom of the screen. /\x2f On desktop, add a bit of padding so it's not flush against the edge. let height = window.innerHeight - y - padding; this._dropdown.root.style.maxHeight = \`\${height}px\`; } else { y -= verticalPadding; /\x2f Align to the top of the button. this._dropdown.root.style.top = ""; this._dropdown.root.style.bottom = \`calc(100% - \${y}px)\`; /\x2f Set the box's maxHeight so it doesn't cross the top of the screen. let height = y - padding; this._dropdown.root.style.maxHeight = \`\${height}px\`; } } shutdown() { this._cleanup(); /\x2f Call the base shutdown() after cleaning up so our shutdown signal isn't fired /\x2f until after we're done cleaning up. super.shutdown(); } /\x2f Return true if this popup should close when clicking inside it. If false, /\x2f the menu will stay open until something else closes it. get closeOnClickInside() { return false; } } /\x2f A specialization of DropdownBoxOpener for buttons that open dropdowns containing /\x2f lists of buttons, which we use a lot for data source UIs. export class DropdownMenuOpener extends DropdownBoxOpener { /\x2f When button is clicked, show box. constructor({ createDropdown=null, ...options }) { super({ /\x2f Wrap createDropdown() to add the popup-menu-box class. createDropdown: (...args) => { let widget = createDropdown(...args); widget.root.classList.add("popup-menu-box"); return widget; }, ...options }); this.button.addEventListener("click", (e) => this._buttonClicked(e), this._signal); this.setButtonPopupHighlight(); } get closeOnClickInside() { return true; } /\x2f Close the popup when something inside is clicked. This can be prevented with /\x2f stopPropagation, or with the keep-menu-open class. boxClicked = (e) => { if(e.target.closest(".keep-menu-open")) return; this.visible = false; } /\x2f Toggle the popup when the button is clicked. _buttonClicked(e) { e.preventDefault(); e.stopPropagation(); this.visible = !this.visible; } /\x2f Set the text and highlight on button based on the contents of the box. /\x2f /\x2f The data source dropdowns originally created all of their contents, then we set the /\x2f button text by looking at the contents. We now create the popups on demand, but we /\x2f still want to set the button based on the selection. Do this by creating a temporary /\x2f dropdown so we can see what gets set. This is tightly tied to DataSource.setItem. setButtonPopupHighlight() { let tempBox = this.createDropdown({container: document.body}); DropdownMenuOpener.setActivePopupHighlightFrom(this.button, tempBox.root); tempBox.shutdown(); } static setActivePopupHighlightFrom(button, box) { /\x2f Find the selected item in the dropdown, if any. let selectedItem = box.querySelector(".selected"); let selectedDefault = selectedItem == null || selectedItem.dataset["default"]; /\x2f If an explicit default button exists, there's usually always something selected in the /\x2f list: either a filter is selected or the default is. If a list has a default button /\x2f but nothing is selected at all, that means we're not on any of the available selections /\x2f (we don't even match the default). For example, this can happen if "This Week" is selected, /\x2f but some time has passed, so the time range the "This Week" menu item points to doesn't match /\x2f the search. (That means we're viewing "some week in the past", but we don't have a menu item /\x2f for it.) /\x2f /\x2f If this happens, show the dropdown as selected, even though none of its items are active, to /\x2f indicate that a filter really is active and the user can reset it. let itemHasDefault = box.querySelector("[data-default]") != null; if(itemHasDefault && selectedItem == null) selectedDefault = false; helpers.html.setClass(button, "selected", !selectedDefault); helpers.html.setClass(box, "selected", !selectedDefault); /\x2f If an option is selected, replace the menu button text with the selection's label. if(!selectedDefault) { /\x2f The short label is used to try to keep these labels from causing the menu buttons to /\x2f overflow the container, and for labels like "2 years ago" where the menu text doesn't /\x2f make sense. /\x2f /\x2f If we don't have a selected item, we're in the itemHasDefault case (see above). let text = selectedItem?.dataset?.shortLabel; let selectedLabel = selectedItem?.querySelector(".label")?.innerText; let label = button.querySelector(".label"); label.innerText = text ?? selectedLabel ?? "Other"; } } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/dropdown.js `), "/vview/widgets/folder-tree.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; import LocalAPI from '/vview/misc/local-api.js'; import { helpers, GuardedRunner } from '/vview/misc/helpers.js'; class TreeWidget extends Widget { constructor({ addRoot=true, ...options}) { super({...options, template: \`
\`}); this._labelPopup = this.createTemplate({html: \`
\`}); this._thumbPopup = this.createTemplate({html: \`
\`}); this.items = this.root.querySelector(".items"); /\x2f Listen to illust changes so we can refresh nodes. ppixiv.mediaCache.addEventListener("mediamodified", this._mediaModified, { signal: this.shutdownSignal }); /\x2f Create the root item. This is TreeWidgetItem or a subclass. if(addRoot) { let rootItem = new TreeWidgetItem({ parent: this, label: "root", rootItem: true, }); this.setRootItem(rootItem); } } _mediaModified = (e) => { if(this.rootItem == null) return; for(let node of Object.values(this.rootItem.nodes)) { if(node.illustChanged) node.illustChanged(e.mediaId); } } setRootItem(rootItem) { if(this.rootItem == rootItem) return; /\x2f If we have another root, remove it from this.items. if(this.rootItem) { this.rootItem.root.remove(); this.rootItem = null; } this.rootItem = rootItem; /\x2f Add the new root to this.items. if(rootItem.root.parentNode != this.items) { console.assert(rootItem.parentNode == null); this.items.appendChild(rootItem.root); } /\x2f Root nodes are always expanded. rootItem.expanded = "user"; } setSelectedItem(item) { if(this.selectedItem == item) return; this.selectedItem = item; for(let node of this.root.querySelectorAll(".tree-item.selected")) { node.classList.remove("selected"); /\x2f Collapse any automatically-expanded nodes that we're navigating out of, as /\x2f long as they're not an ancestor of the parent of the node we're expanding. /\x2f We don't need to keep the item itself expanded. node.widget._collapseAutoExpanded({untilAncestorOf: item?.parent}); } if(item != null) { item.root.classList.add("selected"); /\x2f If the item isn't visible, center it. /\x2f /\x2f Bizarrely, while there's a full options dict for scrollIntoView and you /\x2f can control horizontal and vertical scrolling separately, there's no "none" /\x2f option so you can scroll vertically and not horizontally. let scrollContainer = this.root; let label = item.root.querySelector(".label"); let oldScrollLeft = scrollContainer.scrollLeft; label.scrollIntoView({ block: "nearest" }); scrollContainer.scrollLeft = oldScrollLeft; } } /\x2f Update the hover popup. This allows seeing the full label without needing /\x2f a horizontal scroller, and lets us display a quick thumbnail. setHover(item) { let img = this._thumbPopup.querySelector("img"); if(item == null) { /\x2f Remove the hover, and clear the image so it doesn't flicker the next time /\x2f we display it. img.src = helpers.other.blankImage; this._labelPopup.remove(); this._thumbPopup.remove(); return; } let label = item.root.querySelector(".label"); let {top, left, bottom, height} = label.getBoundingClientRect(); /\x2f Set up thumbPopup. if(item.path) { let {right} = this.root.getBoundingClientRect(); this._thumbPopup.style.left = \`\${right}px\`; /\x2f If the label is above halfway down the screen, position the preview image /\x2f below it. Otherwise, position it below. This keeps the image from overlapping /\x2f the label. We don't know the dimensions of the image here. let labelCenter = top + height/2; let belowMiddle = labelCenter > window.innerHeight/2; if(belowMiddle) { /\x2f Align the bottom of the image to the top of the label. this._thumbPopup.style.top = \`\${top - 20}px\`; img.style.objectPosition = "left bottom"; this._thumbPopup.style.transform = "translate(0, -100%)"; } else { /\x2f Align the top of the image to the bottom of the label. this._thumbPopup.style.top = \`\${bottom+20}px\`; img.style.objectPosition = "left top"; this._thumbPopup.style.transform = ""; } /\x2f Don't show a thumb for roots. Searches don't have thumbnails, and it's not useful /\x2f for most others. img.hidden = item.isRootItem; img.crossOriginMode = "use-credentials"; if(!item.isRootItem) { /\x2f Use /tree-thumb for these thumbnails. They're the same as the regular thumbs, /\x2f but it won't give us a folder image if there's no thumb. let url = LocalAPI.localUrl; url.pathname = "tree-thumb/" + item.path; img.src = url; img.addEventListener("img", (e) => { console.log("error"); img.hidden = true; }); } document.body.appendChild(this._thumbPopup); } /\x2f Set up labelPopup. { this._labelPopup.style.left = \`\${left}px\`; this._labelPopup.style.top = \`\${top}px\`; /\x2f Match the padding of the label. this._labelPopup.style.padding = getComputedStyle(label).padding; this._labelPopup.querySelector(".label").innerText = item.label; document.body.appendChild(this._labelPopup); } } } class TreeWidgetItem extends Widget { /\x2f If rootItem is true, this is the root item being created by a TreeWidget. Our /\x2f parent is the TreeWidget and our container is TreeWidget.items. /\x2f /\x2f If rootItem is false (all items created by the user) and parent is a TreeWidget, our /\x2f real parent is the TreeWidget's root item. Otherwise, parent is always another /\x2f TreeWidgetItem. constructor({ parent, label, rootItem=false, /\x2f If true, this item might have children. The first time the user expands /\x2f it, onexpand() will be called to populate it. pending=false, expandable=false, ...options }={}) { /\x2f If this isn't a root node and parent is a TreeWidget, use the TreeWidget's /\x2f root node as our parent instead of the tree widget itself. if(!rootItem && parent instanceof TreeWidget) parent = parent.rootItem; super({...options, /\x2f The container is our parent node's item list. container: parent.items, template: \`
▶ ⌛
\`}); /\x2f If this is the root node, hide .self, and add .rootItem so our children /\x2f aren't indented. if(rootItem) { this.root.querySelector(".self").hidden = true; this.root.classList.add("root-item"); } /\x2f If our parent is the root node, we're a top-level node. helpers.html.setClass(this.root, "top", !rootItem && parent.rootItem); helpers.html.setClass(this.root, "child", !rootItem && !parent.rootItem); this.items = this.root.querySelector(".items"); this.expander = this.root.querySelector(".expander"); this.isRootItem = rootItem; this._expandable = expandable; this._expanded = false; this._pending = pending; this._label = label; /\x2f Our root node: this.rootNode = rootItem? this:this.parent.rootNode; /\x2f If we're the root node, the tree is our parent. Otherwise, copy the tree from /\x2f our parent. this.tree = rootItem? this.parent:this.parent.tree; this.expander.addEventListener("click", (e) => { this.expanded = this.expanded? false:"user"; }); let labelElement = this.root.querySelector(".label"); labelElement.addEventListener("dblclick", this.ondblclick); labelElement.addEventListener("mousedown", (e) => { if(e.button != 0) return; e.preventDefault(); e.stopImmediatePropagation(); this.select({user: true}); this.onclick(); }, { capture: true }); labelElement.addEventListener("mouseover", (e) => { this.tree.setHover(this); }, { capture: false, }); labelElement.addEventListener("mouseout", (e) => { this.tree.setHover(null); }, { capture: false, }); this._refreshExpandMode(); if(this.parent instanceof TreeWidgetItem) { this.parent._refreshExpandMode(); } /\x2f Refresh the label. this.refresh(); } get label() { return this._label; } refresh() { let label = this.root.querySelector(".label"); label.innerText = this.label; } /\x2f This is called if pending is set to true the first time the node is expanded. /\x2f Return true on success, or false to re-collapse the node on error. async onexpand() { return true; } /\x2f This is called when the item is clicked. onclick() { } /\x2f Expanded is false (collapsed), "auto" (expanded due to navigation), or user (expanded by the user). set expanded(value) { if(this._expanded == value) return; /\x2f If we're already expanded by the user, don't downgrade to automatically expanded. if(value == "auto" && this._expanded == "user") return; /\x2f Don't unexpand the root. if(!value && this.isRootItem) return; this._expanded = value; /\x2f If we're pending, call onexpand the first time we're expanded so we can /\x2f be populated. We'll stay pending and showing the hourglass until onexpand /\x2f completes. if(this._expanded) this._loadContents(); this._refreshExpandMode(); } async _loadContents() { /\x2f Stop if we're already loaded. if(!this._pending) return; if(this._loadPromise != null) { try { await this._loadPromise; } catch(e) { /\x2f The initial call to _loadContents will print the error. } return; } /\x2f Start a load if one isn't already running. /\x2f Start the load. this._loadPromise = this.onexpand(); this._loadPromise.finally(() => { this.pending = false; this._loadPromise = null; }); try { if(await this._loadPromise) return; } catch(e) { console.log("Error expanding", this, e); } /\x2f If onexpand() threw an exception or returned false, there was an error loading the /\x2f node. Unexpand it rather than leaving it marked complete, so it can be retried. this._pending = true; this._expanded = false; this._refreshExpandMode(); } set expandable(value) { if(this._expandable == value) return; this._expandable = value; this._refreshExpandMode(); } set pending(value) { if(this._pending == value) return; this._pending = value; this._refreshExpandMode(); } get expanded() { return this._expanded;} get expandable() { return this._expandable; } get pending() { return this._pending; } /\x2f Return an array of this node's child TreeWidgetItems. get childNodes() { let result = []; for(let child = this.items.firstElementChild; child != null; child = child.nextElementSibling) if(child.widget) result.push(child.widget); return result; } get displayedExpandMode() { /\x2f If we're not pending and we have no children, show "none". if(!this._pending && this.items.firstElementChild == null) return "none"; /\x2f If we're expanded and pending, show "loading". We're waiting for onexpand /\x2f to finish loading and unset pending. if(this.expanded) return this._pending? "loading":"expanded"; return "expandable"; } _refreshExpandMode() { this.expander.dataset.mode = this.displayedExpandMode; this.expander.dataset.pending = this._pending; this.items.hidden = !this._expanded || this._pending; helpers.html.setClass(this.root, "allow-content-visibility", this.displayedExpandMode != "expanded"); } /\x2f user is true if the item is being selected by the user, so it shouldn't be automatically /\x2f collapsed, or false if it's being selected automatically. select({user=false}={}) { this.tree.setSelectedItem(this); /\x2f If the user clicks an item, mark it as user-expanded if it was previously automatically /\x2f expanded. if(user) this._commitUserExpanded(); } /\x2f Mark this item and all of its ancestors as expanded by the user. This will prevent this tree /\x2f from being collapsed automatically when the user navigates away from it. _commitUserExpanded() { let widget = this; while(widget != null && !widget.isRootItem) { if(widget.expanded) widget.expanded = "user"; widget = widget.parent; } } /\x2f If this item was automatically expanded, collapse it, and repeat on our parent nodes. /\x2f /\x2f If untilAncestorOf is given, stop collapsing nodes if we reach an ancestor of that /\x2f node. For example, if we're navigating from "a/b/c/d/e" and untilAncestorOf is /\x2f "a/b/f/g/h", we'll stop when we reach the shared ancestor, "a/b". This prevents us /\x2f from collapsing nodes that the new selection will want expanded. _collapseAutoExpanded({untilAncestorOf}={}) { /\x2f Make a set of ancestor nodes we'll stop at. let stopNodes = new Set(); for(let node = untilAncestorOf; node != null; node = node.parent) stopNodes.add(node); let widget = this; while(widget != null && !widget.isRootItem) { /\x2f Stop if we've reached a shared ancestor. if(stopNodes.has(widget)) break; if(widget.expanded == "auto") widget.expanded = false; widget = widget.parent; } } focus() { this.root.querySelector(".self").focus(); } remove() { if(this.parent == null) return; this.parent.items.remove(this.root); /\x2f Refresh the parent in case we're the last child. this.parent._refreshExpandMode(); this.parent = null; } ondblclick = async(e) => { e.preventDefault(); e.stopImmediatePropagation(); console.log("ondblclick"); this.expanded = this.expanded? false:"user"; /\x2f Double-clicking the tree expands the node. It also causes it to be viewed due /\x2f to the initial single-click. However, if you double-click a directory that's /\x2f full of images, the natural thing for it to do is to view the first image. If /\x2f we don't do that, every time you view a directory you have to click it in the /\x2f tree, then click the first image in the search. /\x2f /\x2f Try to do this intelligently. If the directory we're loading is almost all images, /\x2f navigate to the first image. Otherwise, just let the click leave us viewing the /\x2f directory. This way, double-clicking a directory that has a bunch of other directories /\x2f in it will just expand the node, but double-clicking a directory which is a collection /\x2f of images will view the images. /\x2f /\x2f If we do this, we'll do both navigations: first to the directory and then to the image. /\x2f That's useful, so if we display the image but you really did want the directory view, /\x2f you can just back out once. /\x2f /\x2f Wait for contents to be loaded so we can see if there are any children. console.log("loading on dblclick"); await this._loadContents(); /\x2f If there are any children that we just expanded, stop. if(this.childNodes.length != 0) return; /\x2f The dblclick should have set the data source to this entry. Grab the /\x2f data source. let dataSource = ppixiv.app.currentDataSource; console.log("data source for double click:", dataSource); /\x2f Load the first page. This will overlap with the search loading it, and /\x2f will wait on the same request. if(!dataSource.idList.isPageLoaded(1)) await dataSource.loadPage(1); /\x2f Navigate to the first image on the first page. let mediaIds = dataSource.idList.mediaIdsByPage.get(1); console.log("files for double click:", mediaIds?.length); if(mediaIds != null) ppixiv.app.showMediaId(mediaIds[0], {addToHistory: true, source: "dblclick"}); } }; class LocalNavigationWidgetItem extends TreeWidgetItem { constructor({path, ...options}={}) { super({...options, expandable: true, pending: true, }); this.options = options; this.path = path; /\x2f Set the ID on the item to let the popup menu know what it is. Don't do /\x2f this for top-level libraries ("folder:/images"), since they can't be /\x2f bookmarked. let { id } = helpers.mediaId.parse(this.path); let isLibrary = id.indexOf("/", 1) == -1; if(!isLibrary) this.root.dataset.mediaId = this.path; if(options.rootItem) { /\x2f As we load nodes in this tree, we'll index them by ID here. this.nodes = {}; this.nodes[path] = this; } } /\x2f This is called by the tree when an illust changes to let us refresh, so we don't need /\x2f to register an illust change callback for every node. illustChanged(mediaId) { /\x2f Refresh if we're displaying the illust that changed. if(mediaId == this.path) this.refresh(); } /\x2f In addition to the label, refresh the bookmark icon. refresh() { super.refresh(); /\x2f Show or hide the bookmark icon. let info = ppixiv.mediaCache.getMediaInfoSync(this.path, { full: false }); let bookmarked = info?.bookmarkData != null; this.root.querySelector(".button-bookmark").hidden = !bookmarked; /\x2f This is useful, but the pointless browser URL popup covering the UI is really annoying... /* if(this.path) { let label = this.root.querySelector(".label"); let args = helpers.args.location; LocalAPI.getArgsForId(this.path, args); /\x2f label.href = args.url.toString(); } */ } async onexpand() { return await this.load(); } onclick() { this.tree.showItem(this.path); } load() { if(this.loaded) return Promise.resolve(true); /\x2f If we're already loading this item, just let it complete. if(this._loadPromise) return this._loadPromise; this._loadPromise = this.loadInner(); this._loadPromise.finally(() => { this._loadPromise = null; }); return this._loadPromise; } async loadInner(item) { if(this.loaded) return true; this.loaded = true; let result = await ppixiv.mediaCache.localSearch(this.path, { id: this.path, /\x2f This tells the server to only include directories. It's much faster, since /\x2f it doesn't need to scan images for metadata, and it disables pagination and gives /\x2f us all results at once. directories_only: true, }); if(!result.success) { this.loaded = false; return false; } for(let dir of result.results) { /\x2f Strip "folder:" off of the name, and use the basename of that as the label. let {type } = helpers.mediaId.parse(dir.mediaId); if(type != "folder") continue; let child = new LocalNavigationWidgetItem({ parent: this, label: dir.illustTitle, path: dir.mediaId, }); /\x2f Store ourself on the root node's node list. this.rootNode.nodes[child.path] = child; /\x2f If we're the root, expand our children as they load, so the default tree /\x2f isn't just one unexpanded library. if(this.path == "folder:/") child.expanded = "user"; } return true; } } /\x2f A tree view for navigation with the local image API. export default class LocalNavigationTreeWidget extends TreeWidget { constructor({...options}={}) { super({...options, addRoot: false, }); this._loadPathRunner = new GuardedRunner(this._signal); /\x2f Root LocalNavigationWidgetItems will be stored here when /\x2f set_data_source_search_options is called. Until that happens, we have /\x2f no root. this.roots = {}; window.addEventListener("pp:popstate", (e) => { this.setRootFromUrl(); this.refreshSelection(); }); this.setRootFromUrl(); /\x2f Display the initial selection. Mark this as user-expanded, so it won't be automatically /\x2f collapsed. this.refreshSelection({ user: true }); } /\x2f Choose a tree root for the current URL, creating one if needed. setRootFromUrl() { /\x2f Don't load a root if we're not currently on local search. let args = helpers.args.location; if(args.path != LocalAPI.path) return; if(this._root == null) { /\x2f Create this tree. this._root = new LocalNavigationWidgetItem({ parent: this, label: "/", rootItem: true, path: "folder:/", }); } this.setRootItem(this._root); } setRootItem(rootItem) { super.setRootItem(rootItem); /\x2f Make sure the new root is loaded. rootItem.load(); } /\x2f If a search is active, select its item. async refreshSelection({user=false}={}) { if(this.rootItem == null) return; /\x2f If we're not on a /local/ search, just deselect. let args = helpers.args.location; if(args.path != LocalAPI.path) { this.setSelectedItem(null); return; } /\x2f Load the path if possible and select it. let node = await this._loadPathRunner.call(this.loadPath.bind(this), { args, user }); if(node) { node.select({user}); return; } } /\x2f Load and expand each component of path. If user is true, the item is marked /\x2f user-expanded, otherwise automatically-expanded. /\x2f /\x2f This call is guarded, so if we're called again from another navigation, /\x2f we won't keep loading and changing the selection. async loadPath({ args, user=false, signal }={}) { /\x2f Stop if we don't have a root yet. if(this.rootItem == null) return; /\x2f Wait until the root is loaded, if needed. await this.rootItem.load(); signal.throwIfAborted(); let mediaId = LocalAPI.getLocalIdFromArgs(args, { getFolder: true }); let { id } = helpers.mediaId.parse(mediaId); /\x2f Split apart the path. let parts = id.split("/"); /\x2f Discard the last component. We only need to load the directory containing the /\x2f path, not the directory itself. parts.splice(parts.length-1, 1); /\x2f Incrementally load each directory component. /\x2f /\x2f Note that if we're showing a search, items at the top of the tree will be from /\x2f random places further down the filesystem. We can do the same thing here: if /\x2f we're trying to load /a/b/c/d/e and the search node points to /a/b/c, we skip /\x2f /a and /a/b which aren't in the tree and start loading from there. let currentPath = ""; let node = null; for(let part of parts) { /\x2f Append this path component to currentPath. if(currentPath == "") currentPath = "folder:/"; else if(currentPath != "folder:/") currentPath += "/"; currentPath += part; /\x2f If this directory exists in the tree, it'll be in nodes by now. node = this.rootItem.nodes[currentPath]; if(node == null) { /\x2f console.log("Path doesn't exist:", currentPath); continue; } /\x2f Expand the node. This will trigger a load if needed. node.expanded = user? "user":"auto"; /\x2f If the node is loading, wait for the load to finish. if(node._loadPromise) await node._loadPromise; signal.throwIfAborted(); } return this.rootItem.nodes[mediaId]; } /\x2f Navigate to mediaId, which should be an entry in the current tree. showItem(mediaId) { let args = new helpers.args(ppixiv.plocation); LocalAPI.getArgsForId(mediaId, args); helpers.navigate(args); /\x2f Hide the hover thumbnail on click to get it out of the way. this.setHover(null); } }; /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/folder-tree.js `), "/vview/widgets/illust-widgets.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; import Actor from '/vview/actors/actor.js'; import Actions from '/vview/misc/actions.js'; import AsyncLookup from '/vview/actors/async-lookup.js'; import { ConfirmPrompt } from '/vview/widgets/prompts.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f A helper to allow widgets to display something for a media ID without having to /\x2f deal with loading media info directly. /\x2f /\x2f let getMediaInfo = new GetMediaInfo({ /\x2f onrefresh=({mediaId, mediaInfo}) { /* update with the new data */ } /\x2f }); /\x2f getMediaInfo.id = mediaId; /\x2f /\x2f onrefresh will be called when the media ID changes, media info becomes available /\x2f or changes. /\x2f /\x2f If a media ID is set but media info isn't available immediately, onrefresh will be /\x2f called with mediaInfo == null to allow the display to be cleared, and then called /\x2f again once data is available. export class GetMediaInfo extends AsyncLookup { constructor({ /\x2f The data this widget needs. This can be mediaId (nothing but the ID), full or partial. /\x2f /\x2f This can change dynamically. Some widgets need media info only when viewing a manga /\x2f page. neededData="full", ...options }) { super({...options}); this._neededData = neededData; if(!(this._neededData instanceof Function)) this._neededData = () => neededData; this._info = { } /\x2f Refresh when the image data changes. ppixiv.mediaCache.addEventListener("mediamodified", (e) => { if(e.mediaId == this._id) this.refresh(); }, this._signal); } /\x2f For convenience, return the current manga page. get mangaPage() { let [illustId, page] = helpers.mediaId.toIllustIdAndPage(this._id); return page; } async _refreshInner() { /\x2f Grab the illust info. let mediaId = this._id; this._info = { mediaId: this._id }; /\x2f If we have a media ID and we want media info (not just the media ID itself), load /\x2f the info. let neededData = this._neededData(); if(this._id != null && neededData != "mediaId") { let full = neededData == "full"; /\x2f See if we have the data the widget wants already. this._info.mediaInfo = ppixiv.mediaCache.getMediaInfoSync(this._id, { full }); /\x2f If we need to load data, clear the widget while we load, so we don't show the old /\x2f data while we wait for data. Skip this if we don't need to load, so we don't clear /\x2f and reset the widget. This can give the widget an illust ID without data, which is /\x2f OK. if(this._info.mediaInfo == null) await this._onrefresh(this._info); this._info.mediaInfo = await ppixiv.mediaCache.getMediaInfo(this._id, { full }); } /\x2f Stop if the media ID changed while we were async. if(this._id != mediaId) return; await this._onrefresh(this._info); } } /\x2f A widget that shows info for a particular media ID, and refreshes if the image changes. export class IllustWidget extends Widget { constructor(options) { super(options); this.getMediaInfo = new GetMediaInfo({ parent: this, neededData: () => this.neededData, onrefresh: async(info) => this.refreshInternal(info), }); } get neededData() { return "full"; } get _mediaId() { return this.getMediaInfo.id; } setMediaId(mediaId) { this.getMediaInfo.id = mediaId; } get mediaId() { return this.getMediaInfo.id; } get mangaPage() { return this.getMediaInfo.mangaPage; } refresh() { super.refresh(); this.refreshInternal(this.getMediaInfo.info); } async refreshInternal({ mediaId, mediaInfo }) { throw "Not implemented"; } } export class BookmarkButtonWidget extends IllustWidget { get neededData() { return "partial"; } constructor({ /\x2f The caller provides the template. template=null, /\x2f "public", "private" or "delete" bookmarkType, /\x2f If true, clicking a bookmark button that's already bookmarked will remove the /\x2f bookmark. If false, the bookmark tags will just be updated. toggleBookmark=true, /\x2f An associated BookmarkTagListWidget. /\x2f /\x2f Bookmark buttons and the tag list widget both manipulate and can create bookmarks. Telling /\x2f us about an active bookmarkTagListWidget lets us prevent collisions. bookmarkTagListWidget, ...options}) { console.assert(template != null), super({ template, ...options, }); this.bookmarkType = bookmarkType; this.toggleBookmark = toggleBookmark; this._bookmarkTagListWidget = bookmarkTagListWidget; this.root.addEventListener("click", this.clickedBookmark); if(bookmarkType == "public") this.bookmarkCountWidget = new BookmarkCountWidget({ container: this.root }); } /\x2f Dispatch bookmarkedited when we're editing a bookmark. This lets any bookmark tag /\x2f dropdowns know they should close. _fireOnEdited() { this.dispatchEvent(new Event("bookmarkedited")); } /\x2f Set the associated bookmarkTagListWidget. /\x2f /\x2f Bookmark buttons and the tag list widget both manipulate and can create bookmarks. Telling /\x2f us about an active bookmarkTagListWidget lets us prevent collisions. set bookmarkTagListWidget(value) { this._bookmarkTagListWidget = value; } get bookmarkTagListWidget() { return this._bookmarkTagListWidget; } refreshInternal({ mediaId, mediaInfo }) { if(this.bookmarkCountWidget) this.bookmarkCountWidget.setMediaId(mediaId); /\x2f If this is a local image, we won't have a bookmark count, so set local-image /\x2f to remove our padding for it. We can get mediaId before mediaInfo. let isLocal = helpers.mediaId.isLocal(mediaId); let isPublic = this.bookmarkType == "public"; helpers.html.setClass(this.root, "has-like-count", isPublic && !isLocal); let { type } = helpers.mediaId.parse(mediaId); /\x2f Hide the private bookmark button for local IDs. if(this.bookmarkType == "private") this.root.hidden = isLocal; let bookmarked = mediaInfo?.bookmarkData != null; let privateBookmark = this.bookmarkType == "private"; let isOurBookmarkType = mediaInfo?.bookmarkData?.private == privateBookmark; let willDelete = this.toggleBookmark && isOurBookmarkType; if(this.bookmarkType == "delete") isOurBookmarkType = willDelete = bookmarked; /\x2f Set up the bookmark buttons. helpers.html.setClass(this.root, "enabled", mediaInfo != null); helpers.html.setClass(this.root, "bookmarked", isOurBookmarkType); helpers.html.setClass(this.root, "will-delete", willDelete); /\x2f Set the tooltip. this.root.dataset.popup = mediaInfo == null? "": !bookmarked && this.bookmarkType == "folder"? "Bookmark folder": !bookmarked && this.bookmarkType == "private"? "Bookmark privately": !bookmarked && this.bookmarkType == "public" && type == "folder"? "Bookmark folder": !bookmarked && this.bookmarkType == "public"? "Bookmark image": willDelete? "Remove bookmark": "Change bookmark to " + this.bookmarkType; } /\x2f Clicked one of the top-level bookmark buttons or the tag list. clickedBookmark = async(e) => { /\x2f See if this is a click on a bookmark button. let a = e.target.closest(".button-bookmark"); if(a == null) return; e.preventDefault(); e.stopPropagation(); /\x2f If the tag list dropdown is open, make a list of tags selected in the tag list dropdown. /\x2f If it's closed, leave tagList null so we don't modify the tag list. let tagList = null; if(this._bookmarkTagListWidget && this._bookmarkTagListWidget.visibleRecursively) tagList = this._bookmarkTagListWidget.selectedTags; /\x2f If we have a tag list dropdown, tell it to become inactive. It'll continue to /\x2f display its contents, so they don't change during transitions, but it won't make /\x2f any further bookmark changes. This prevents it from trying to create a bookmark /\x2f when it closes, since we're doing that already. if(this._bookmarkTagListWidget) this._bookmarkTagListWidget.deactivate(); this._fireOnEdited(); let mediaInfo = await ppixiv.mediaCache.getMediaInfo(this._mediaId, { full: false }); let privateBookmark = this.bookmarkType == "private"; /\x2f If the image is bookmarked and a delete bookmark button or the same privacy button was clicked, remove the bookmark. let deleteBookmark = this.toggleBookmark && mediaInfo.bookmarkData?.private == privateBookmark; if(this.bookmarkType == "delete") deleteBookmark = true; if(deleteBookmark) { if(!mediaInfo.bookmarkData) return; /\x2f Confirm removing bookmarks when on mobile. if(ppixiv.mobile) { let result = await (new ConfirmPrompt({ header: "Remove bookmark?" })).result; if(!result) return; } let mediaId = this._mediaId; await Actions.bookmarkRemove(this._mediaId); /\x2f If the current image changed while we were async, stop. if(mediaId != this._mediaId) return; /\x2f Hide the tag dropdown after unbookmarking, without saving any tags in the /\x2f dropdown (that would readd the bookmark). if(this._bookmarkTagListWidget) this._bookmarkTagListWidget.deactivate(); this._fireOnEdited(); return; } /\x2f Add or edit the bookmark. await Actions.bookmarkAdd(this._mediaId, { private: privateBookmark, tags: tagList, }); } } export class BookmarkCountWidget extends IllustWidget { constructor({ ...options }) { super({ ...options, template: \`
\` }); } refreshInternal({ mediaId, mediaInfo }) { let text = ""; if(!helpers.mediaId.isLocal(mediaId)) text = mediaInfo?.bookmarkCount ?? "---"; this.root.textContent = text; } } export class LikeButtonWidget extends IllustWidget { get neededData() { return "mediaId"; } constructor({ /\x2f The caller provides the template. template=null, ...options }) { console.assert(template != null), super({ template, ...options, }) this.root.addEventListener("click", this.clickedLike); this.likeCount = new LikeCountWidget({ container: this.root }); } async refreshInternal({ mediaId }) { this.likeCount.setMediaId(mediaId); /\x2f Hide the like button for local IDs. this.root.closest(".button-container").hidden = helpers.mediaId.isLocal(mediaId); let likedRecently = mediaId != null? ppixiv.extraCache.getLikedRecently(mediaId):false; helpers.html.setClass(this.root, "liked", likedRecently); helpers.html.setClass(this.root, "enabled", !likedRecently); this.root.dataset.popup = this._mediaId == null? "": likedRecently? "Already liked image":"Like image"; } clickedLike = (e) => { e.preventDefault(); e.stopPropagation(); if(this._mediaId != null) Actions.likeImage(this._mediaId); } } export class LikeCountWidget extends IllustWidget { constructor({ ...options }) { super({ ...options, template: \`
\` }); } async refreshInternal({ mediaId, mediaInfo }) { let text = ""; if(!helpers.mediaId.isLocal(mediaId)) text = mediaInfo?.likeCount ?? "---"; this.root.textContent = text; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/illust-widgets.js `), "/vview/widgets/local-widgets.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; import { IllustWidget } from '/vview/widgets/illust-widgets.js'; import { DropdownBoxOpener } from '/vview/widgets/dropdown.js'; import { helpers } from '/vview/misc/helpers.js'; import LocalAPI from '/vview/misc/local-api.js'; /\x2f LocalSearchBoxWidget and LocalSearchDropdownWidget are dumb copy-pastes /\x2f of TagSearchBoxWidget and TagSearchDropdownWidget. They're simpler and /\x2f much less used, and it didn't seem worth creating a shared base class for these. export class LocalSearchBoxWidget extends Widget { constructor({...options}) { super({ ...options, template: \` \` }); this.inputElement = this.root.querySelector(".input-field-container > input"); this.dropdownOpener = new DropdownBoxOpener({ button: this.inputElement, createDropdown: ({...options}) => { return new LocalSearchDropdownWidget({ inputElement: this.root, focusParent: this.root, ...options, }); }, shouldCloseForClick: (e) => { /\x2f Ignore clicks inside our container. if(helpers.html.isAbove(this.root, e.target)) return false; return true; }, }); this.inputElement.addEventListener("keydown", (e) => { /\x2f Exit the search box if escape is pressed. if(e.key == "Escape") { this.dropdownOpener.visible = false; this.inputElement.blur(); } }); this.inputElement.addEventListener("focus", () => this.dropdownOpener.visible = true); this.inputElement.addEventListener("submit", this.submitSearch); this.clearSearchButton = this.root.querySelector(".clear-local-search-button"); this.clearSearchButton.addEventListener("click", (e) => { this.inputElement.value = ""; this.inputElement.dispatchEvent(new Event("submit")); }); this.root.querySelector(".submit-local-search-button").addEventListener("click", (e) => { this.inputElement.dispatchEvent(new Event("submit")); }); this.inputElement.addEventListener("input", (e) => { this.refreshClearButtonVisibility(); }); /\x2f Search submission: helpers.inputHandler(this.inputElement, this.submitSearch); window.addEventListener("pp:popstate", (e) => { this.refreshFromLocation(); }); this.refreshFromLocation(); this.refreshClearButtonVisibility(); } /\x2f Hide if our tree becomes hidden. visibilityChanged() { super.visibilityChanged(); if(!this.visibleRecursively) this.dropdownOpener.visible = false; } /\x2f SEt the text box from the current URL. refreshFromLocation() { let args = helpers.args.location; this.inputElement.value = args.hash.get("search") || ""; this.refreshClearButtonVisibility(); } refreshClearButtonVisibility() { this.clearSearchButton.hidden = this.inputElement.value == ""; } submitSearch = (e) => { let tags = this.inputElement.value; LocalAPI.navigateToTagSearch(tags); /\x2f If we're submitting by pressing enter on an input element, unfocus it and /\x2f close any widgets inside it (tag dropdowns). if(e.target instanceof HTMLInputElement) { e.target.blur(); this.dropdownOpener.visible = false; } } } class LocalSearchDropdownWidget extends Widget { constructor({inputElement, focusParent, ...options}) { super({...options, template: \`
\`}); this.inputElement = inputElement; /\x2f While we're open, we'll close if the user clicks outside focusParent. this.focusParent = focusParent; /\x2f Refresh the dropdown when the search history changes. window.addEventListener("recent-local-searches-changed", this._populateDropdown); this.root.addEventListener("click", this.dropdownClick); /\x2f input-dropdown is resizable. Save the size when the user drags it. this._inputDropdown = this.root.querySelector(".input-dropdown-list"); /\x2f Restore input-dropdown's width. let refreshDropdownWidth = () => { let width = ppixiv.settings.get("tag-dropdown-width", "400"); width = parseInt(width); if(isNaN(width)) width = 400; this.root.style.setProperty('--width', \`\${width}px\`); }; let observer = new MutationObserver((mutations) => { /\x2f resize sets the width. Use this instead of offsetWidth, since offsetWidth sometimes reads /\x2f as 0 here. ppixiv.settings.set("tag-dropdown-width", this._inputDropdown.style.width); }); observer.observe(this._inputDropdown, { attributes: true }); /\x2f Restore input-dropdown's width. refreshDropdownWidth(); this._load(); } dropdownClick = (e) => { let removeEntry = e.target.closest(".remove-history-entry"); if(removeEntry != null) { /\x2f Clicked X to remove a tag from history. e.stopPropagation(); e.preventDefault(); let tag = e.target.closest(".entry").dataset.tag; this._removeRecentLocalSearch(tag); return; } /\x2f Close the dropdown if the user clicks a tag (but not when clicking /\x2f remove-history-entry). if(e.target.closest(".tag")) this.hide(); } _removeRecentLocalSearch(search) { /\x2f Remove tag from the list. There should normally only be one. let recentTags = ppixiv.settings.get("local_searches") || []; while(1) { let idx = recentTags.indexOf(search); if(idx == -1) break; recentTags.splice(idx, 1); } ppixiv.settings.set("local_searches", recentTags); window.dispatchEvent(new Event("recent-local-searches-changed")); } _load() { /\x2f Fill in the dropdown before displaying it. this._populateDropdown(); } createEntry(search) { let entry = this.createTemplate({name: "tag-dropdown-entry", html: \` X \`}); entry.dataset.tag = search; let span = document.createElement("span"); span.innerText = search; entry.querySelector(".search").appendChild(span); let args = new helpers.args("/", ppixiv.plocation); args.path = LocalAPI.path; args.hashPath = "/"; args.hash.set("search", search); entry.href = args.url; return entry; } /\x2f Populate the tag dropdown. _populateDropdown = () => { let tagSearches = ppixiv.settings.get("local_searches") || []; tagSearches.sort(); let list = this.root.querySelector(".input-dropdown-list"); helpers.html.removeElements(list); for(let tag of tagSearches) { let entry = this.createEntry(tag); entry.classList.add("history"); list.appendChild(entry); } } } /\x2f A button to show an image in Explorer. /\x2f /\x2f This requires view_in_explorer.pyw be set up. export class ViewInExplorerWidget extends IllustWidget { constructor({...options}) { super({ ...options, template: \` \${ helpers.createIcon("description") } \` }); this.enabled = false; this.root.addEventListener("click", (e) => { /\x2f Ignore clicks on the button if it's disabled. if(!this.enabled) { e.preventDefault(); e.stopPropagation(); return; } /\x2f On alt-click, copy the path. if(e.altKey) { e.preventDefault(); e.stopPropagation(); let { mediaInfo } = this.getMediaInfo.info; let localPath = mediaInfo?.localPath; console.log("f", localPath); navigator.clipboard.writeText(localPath); ppixiv.message.show("Path copied to clipboard"); return; } }); } refreshInternal({ mediaId, mediaInfo }) { let path = mediaInfo?.localPath; this.enabled = mediaInfo?.localPath != null; helpers.html.setClass(this.root, "enabled", this.enabled); if(path == null) return; path = path.replace(/\\\\/g, "/"); /\x2f We have to work around some extreme jankiness in the URL API. If we create our /\x2f URL directly and then try to fill in the pathname, it won't let us change it. We /\x2f have to create a file URL, fill in the pathname, then replace the scheme after /\x2f converting to a string. Web! let url = new URL("file:/\x2f/"); url.pathname = path; url = url.toString(); url = url.replace("file:", "vviewinexplorer:") let a = this.root; a.href = url; /\x2f Set the popup for the type of ID. let { type } = helpers.mediaId.parse(mediaId); let popup = type == "file"? "View file in Explorer":"View folder in Explorer"; a.dataset.popup = popup; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/local-widgets.js `), "/vview/widgets/menu-option.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; import { CheckboxWidget, SliderWidget } from '/vview/widgets/simple.js'; import { DropdownMenuOpener } from '/vview/widgets/dropdown.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f Simple menu settings widgets. export class MenuOption extends Widget { constructor({ classes=[], refresh=null, shouldBeVisible=null, ...options }) { super(options); this.explanationNode = this.querySelector(".explanation"); this.shouldBeVisible = shouldBeVisible; /\x2f shouldBeVisible is used to set visibility based on other stetings, so refresh /\x2f visibility when other settings change. if(shouldBeVisible != null) ppixiv.settings.addEventListener("all", () => this.callVisibilityChanged(), this._signal); for(let className of classes) this.root.classList.add(className); this.onrefresh = refresh; } applyVisibility() { if(this.shouldBeVisible == null) return super.applyVisibility(); helpers.html.setClass(this.root, "hidden-widget", !this.shouldBeVisible()); } refresh() { if(this.onrefresh) this.onrefresh(); this.refreshExplanation(); } /\x2f The current explanation text. The subclass can override this. get explanationText() { return null; } /\x2f Update the explanation text, if any. refreshExplanation() { if(this.explanationNode == null) return; let text = this.explanationText; if(typeof(text) == "function") text = text(); this.explanationNode.hidden = text == null; this.explanationNode.innerText = text; } } /\x2f A container for multiple options on a single row. export class MenuOptionRow extends MenuOption { constructor({ label=null, ...options}) { super({...options, template: \` \`}); this.label = label; } set label(label) { let span = this.root.querySelector(".label-box"); span.hidden = label == null; span.innerText = label ?? ""; } } export class MenuOptionButton extends MenuOption { constructor({ url=null, label, getLabel=null, onclick=null, explanationEnabled=null, explanationDisabled=null, popup=null, icon=null, ...options}) { super({...options, template: \` \${helpers.createBoxLink({ label, icon: icon, link: url, popup, classes: ["menu-toggle"], explanation: "", /\x2f create the explanation field })} \`}); this._clickHandler = onclick; this._enabled = true; this.explanationEnabled = helpers.other.makeFunction(explanationEnabled); this.explanationDisabled = helpers.other.makeFunction(explanationDisabled ?? explanationEnabled); this.getLabel = getLabel; if(this._clickHandler != null) this.root.classList.add("clickable"); this.root.querySelector(".label").innerText = label; this.root.addEventListener("click", this.onclick); } refresh() { super.refresh(); if(this.getLabel) this.root.querySelector(".label").innerText = this.getLabel(); } set enabled(value) { helpers.html.setClass(this.root, "disabled", !value); this._enabled = value; } get enabled() { return this._enabled; } get explanationText() { return this.enabled? this.explanationEnabled():this.explanationDisabled(); } onclick = (e) => { if(!this._enabled) { /\x2f Always preventDefault if we're disabled. e.preventDefault(); return; } if(this._clickHandler) { /\x2f XXX: check callers /\x2f e.preventDefault(); this._clickHandler(e); } } } /\x2f A simpler button, used for sub-buttons such as "Edit". export class MenuOptionNestedButton extends MenuOption { constructor({ onclick=null, label, ...options}) { super({...options, template: helpers.createBoxLink({label: "", classes: ["clickable"] })}); this.root.querySelector(".label").innerText = label; this.root.addEventListener("click", (e) => { e.stopPropagation(); e.preventDefault(); onclick(e); }); } } export class MenuOptionToggle extends MenuOptionButton { constructor({ checked=false, ...options }) { super({...options}); this.checkbox = new CheckboxWidget({ container: this.querySelector(".widget-box") }); this.checkbox.checked = checked; } /\x2f The subclass overrides this to get and store its value. get value() { return false; } set value(value) { } get explanationText() { return this.value? this.explanationEnabled:this.explanationDisabled; } } export class MenuOptionToggleSetting extends MenuOptionToggle { constructor({ setting=null, onclick=null, settings=null, /\x2f Most settings are just booleans, but this can be used to toggle between /\x2f string keys. This can make adding more values to the option easier later /\x2f on. A default value should be set in settings.js if this is used. onValue=true, offValue=false, ...options}) { super({...options, onclick: (e) => { if(this.options && this.options.check && !this.options.check()) return; this.value = !this.value; /\x2f Call the user's onclick, if any. if(onclick) onclick(e); }, }); this.settings = settings ?? ppixiv.settings; this.setting = setting; this.onValue = onValue; this.offValue = offValue; if(this.setting) this.settings.addEventListener(this.setting, this.refresh.bind(this), { signal: this.shutdownSignal }); } refresh() { super.refresh(); let value = this.value; if(this.options.invertDisplay) value = !value; this.checkbox.checked = value; } get value() { return this.settings.get(this.setting) == this.onValue; } set value(value) { this.settings.set(this.setting, value? this.onValue:this.offValue); this.refresh(); } } export class MenuOptionSlider extends MenuOption { constructor({ min=null, max=null, /\x2f If set, this is a list of allowed values. list=null, ...options }) { super({...options, template: \` \`}); this.slider = new SliderWidget({ container: this.root, onchange: ({value}) => { this.value = this.sliderValue; } }); this.list = list; if(this.list != null) { min = 0; max = this.list.length - 1; } this.slider.min = min; this.slider.max = max; } refresh() { this.sliderValue = this.value; super.refresh(); } get value() { return parseInt(super.value); } set value(value) { super.value = value; } _sliderIndexToValue(value) { if(this.list == null) return value; return this.list[value]; } _valueToSliderIndex(value) { if(this.list == null) return value; let closestIndex = -1; let closestDistance = null; for(let idx = 0; idx < this.list.length; ++idx) { let v = this.list[idx]; /\x2f Check for exact matches, so the list can contain strings. if(value == v) return idx; let distance = Math.abs(value - v); if(closestDistance == null || distance < closestDistance) { closestIndex = idx; closestDistance = distance; } } return closestIndex; } set sliderValue(value) { value = this._valueToSliderIndex(value); if(this.slider.value == value) return; this.slider.value = value; } get sliderValue() { let value = parseInt(this.slider.value); value = this._sliderIndexToValue(value); return value; } } export class MenuOptionSliderSetting extends MenuOptionSlider { constructor({ setting, settings=null, ...options}) { super(options); this.setting = setting; this.settings = settings ?? ppixiv.settings; } get minValue() { return this.options.min; } get maxValue() { return this.options.max; } get value() { return this.settings.get(this.setting); } set value(value) { this.settings.set(this.setting, value); this.refresh(); } }; /\x2f A menu option widget for settings that come from a list of options. This would /\x2f make more sense as a dropdown, but for now it uses a slider. export class MenuOptionOptionsSetting extends MenuOptionButton { constructor({setting, label, values, explanation, settings=null, ...options}) { super({ ...options, label: label, }); this._getExplanation = explanation; this.settings = settings ?? ppixiv.settings; this.setting = setting; this.button = helpers.createBoxLink({ label, icon: "expand_more", classes: ["menu-dropdown-button", "clickable"], asElement: true, }); this.querySelector(".widget-box").appendChild(this.button); this.opener = new DropdownMenuOpener({ button: this.button, createDropdown: ({...options}) => { let dropdown = new Widget({ ...options, template: \`
\`, }); let currentValue = this.value; for(let [value, label] of Object.entries(values)) { let link = helpers.createBoxLink({ label, asElement: true }); helpers.html.setClass(link, "selected", value == currentValue); dropdown.root.appendChild(link); link.addEventListener("click", () => { this.value = value; }); } return dropdown; }, }); } get value() { return this.settings.get(this.setting); } set value(value) { this.settings.set(this.setting, value); this.refresh(); } refresh() { super.refresh(); this.opener.setButtonPopupHighlight(); } get explanationText() { if(!this._getExplanation) return null; return this._getExplanation(this.value); } }; /\x2f A widget to control the thumbnail size slider. export class MenuOptionsThumbnailSizeSlider extends MenuOptionSliderSetting { constructor({...options}) { super(options); this.refresh(); } /\x2f Increase or decrease zoom. move(down) { ppixiv.settings.adjustZoom(this.setting, down); } get value() { let value = super.value; if(typeof(value) != "number" || isNaN(value)) value = 4; return value; } set value(value) { super.value = value; } static thumbnailSizeForValue(value) { return 100 * Math.pow(1.3, value); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/menu-option.js `), "/vview/widgets/message-widget.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; /\x2f Display messages in the popup widget. This is a singleton. export default class MessageWidget extends Widget { constructor(options) { super({...options, template: \`
\`, }); this.timer = null; /\x2f Dismiss messages when changing screens. window.addEventListener("screenchanged", (e) => this.hide(), this._signal); } show(message) { console.assert(message != null); console.log(message); this.clearTimer(); this.root.querySelector(".message").innerHTML = message; this.root.classList.add("show"); this.root.classList.remove("centered"); this.timer = realSetTimeout(() => { this.root.classList.remove("show"); }, 3000); } clearTimer() { if(this.timer != null) { realClearTimeout(this.timer); this.timer = null; } } hide() { this.clearTimer(); this.root.classList.remove("show"); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/message-widget.js `), "/vview/widgets/more-options-dropdown.js": loadBlob("application/javascript", `/\x2f The "More..." dropdown menu shown in the options menu. import { SettingsDialog, SettingsPageDialog } from '/vview/widgets/settings-widgets.js'; import { SendImagePopup } from '/vview/misc/send-image.js'; import { MenuOptionButton, MenuOptionToggle, MenuOptionToggleSetting } from '/vview/widgets/menu-option.js'; import { MutedTagsForPostDialog } from '/vview/widgets/mutes.js'; import { MenuOptionToggleImageTranslation } from '/vview/misc/image-translation.js'; import Actions from '/vview/misc/actions.js'; import { IllustWidget } from '/vview/widgets/illust-widgets.js'; import { helpers } from '/vview/misc/helpers.js'; import LocalAPI from '/vview/misc/local-api.js'; export default class MoreOptionsDropdown extends IllustWidget { get neededData() { return "partial"; } constructor({ /\x2f If true, show less frequently used options that are hidden by default to reduce /\x2f clutter. showExtra=false, ...options }) { super({...options, template: \`
\`}); this.showExtra = showExtra; this._menuOptions = []; } _createMenuOptions() { let optionBox = this.root.querySelector(".options"); let sharedOptions = { container: optionBox, parent: this, }; for(let item of this._menuOptions) item.root.remove(); let menuOptions = { similarIllustrations: () => { return new MenuOptionButton({ ...sharedOptions, label: "Similar illustrations", icon: "ppixiv:suggestions", requiresImage: true, onclick: () => { this.parent.hide(); let [illustId] = helpers.mediaId.toIllustIdAndPage(this.mediaId); let args = new helpers.args(\`/bookmark_detail.php?illust_id=\${illustId}#ppixiv?recommendations=1\`); helpers.navigate(args); } }); }, similarArtists: () => { return new MenuOptionButton({ ...sharedOptions, label: "Similar artists", icon: "ppixiv:suggestions", requiresUser: true, onclick: () => { this.parent.hide(); let args = new helpers.args(\`/discovery/users#ppixiv?user_id=\${this._effectiveUserId}\`); helpers.navigate(args); } }); }, similarLocalImages: () => { return new MenuOptionButton({ ...sharedOptions, label: "Similar images", icon: "ppixiv:suggestions", requiresImage: true, onclick: () => { this.parent.hide(); let args = new helpers.args("/"); args.path = "/similar"; args.hashPath = "/#/"; let { id } = helpers.mediaId.parse(this.mediaId); args.hash.set("search_path", id); helpers.navigate(args); } }); }, similarBookmarks: () => { return new MenuOptionButton({ ...sharedOptions, label: "Similar bookmarks", icon: "ppixiv:suggestions", requiresImage: true, onclick: () => { this.parent.hide(); let [illustId] = helpers.mediaId.toIllustIdAndPage(this.mediaId); let args = new helpers.args(\`/bookmark_detail.php?illust_id=\${illustId}#ppixiv\`); helpers.navigate(args); } }); }, indexFolderForSimilaritySearch: () => { return new MenuOptionButton({ ...sharedOptions, label: "Index similarity", icon: "ppixiv:suggestions", hideIfUnavailable: true, requires: ({mediaId}) => { if(mediaId == null) return false; let { type } = helpers.mediaId.parse(mediaId); return type == "folder"; }, onclick: () => { this.parent.hide(); LocalAPI.indexFolderForSimilaritySearch(this.mediaId); } }); }, toggleUpscaling: () => { return new MenuOptionToggleSetting({ ...sharedOptions, label: "GPU upscaling", icon: "mat:zoom_out_map", requiresImage: true, setting: "upscaling", }); }, editMutes: () => { return new MenuOptionButton({ ...sharedOptions, label: "Edit mutes", /\x2f Only show this entry if we have at least a media ID or a user ID. requires: ({mediaId, userId}) => { return mediaId != null || userId != null; }, icon: "mat:block", onclick: async () => { this.parent.hide(); new MutedTagsForPostDialog({ mediaId: this.mediaId, userId: this._effectiveUserId, }); } }); }, refreshImage: () => { return new MenuOptionButton({ ...sharedOptions, label: "Refresh image", requiresImage: true, icon: "mat:refresh", onclick: async () => { this.parent.hide(); ppixiv.mediaCache.refreshMediaInfo(this.mediaId, { refreshFromDisk: true }); } }); }, shareImage: () => { return new MenuOptionButton({ ...sharedOptions, label: "Share image", icon: "mat:share", /\x2f This requires an image and support for the share API. requires: ({mediaId}) => { if(navigator.share == null) return false; if(mediaId == null || helpers.mediaId.isLocal(mediaId)) return false; let mediaInfo = ppixiv.mediaCache.getMediaInfoSync(mediaId, { full: false }); return mediaInfo && mediaInfo.illustType != 2; }, onclick: async () => { let mediaInfo = await ppixiv.mediaCache.getMediaInfo(this._mediaId, { full: true }); let page = helpers.mediaId.parse(this.mediaId).page; let { url } = mediaInfo.getMainImageUrl(page); let title = \`\${mediaInfo.userName} - \${mediaInfo.illustId}\`; if(mediaInfo.mangaPages.length > 1) { let mangaPage = helpers.mediaId.parse(this._mediaId).page; title += " #" + (mangaPage + 1); } title += \`.\${helpers.strings.getExtension(url)}\`; navigator.share({ url, title, }); } }); }, downloadImage: () => { return new MenuOptionButton({ ...sharedOptions, label: "Download image", icon: "mat:download", hideIfUnavailable: true, requiresImage: true, available: () => { return this.mediaInfo && Actions.isDownloadTypeAvailable("image", this.mediaInfo); }, onclick: () => { Actions.downloadIllust(this.mediaId, "image"); this.parent.hide(); } }); }, downloadManga: () => { return new MenuOptionButton({ ...sharedOptions, label: "Download manga ZIP", icon: "mat:download", hideIfUnavailable: true, requiresImage: true, available: () => { return this.mediaInfo && Actions.isDownloadTypeAvailable("ZIP", this.mediaInfo); }, onclick: () => { Actions.downloadIllust(this.mediaId, "ZIP"); this.parent.hide(); } }); }, downloadVideo: () => { return new MenuOptionButton({ ...sharedOptions, label: "Download video MKV", icon: "mat:download", hideIfUnavailable: true, requiresImage: true, available: () => { return this.mediaInfo && Actions.isDownloadTypeAvailable("MKV", this.mediaInfo); }, onclick: () => { Actions.downloadIllust(this.mediaId, "MKV"); this.parent.hide(); } }); }, sendToTab: () => { return new MenuOptionButton({ ...sharedOptions, label: "Send to tab", classes: ["button-send-image"], icon: "mat:open_in_new", requiresImage: true, onclick: () => { new SendImagePopup({ mediaId: this.mediaId }); this.parent.hide(); } }); }, toggleSlideshow: () => { return new MenuOptionToggle({ ...sharedOptions, label: "Slideshow", icon: "mat:wallpaper", requiresImage: true, checked: helpers.args.location.hash.get("slideshow") == "1", onclick: () => { ppixiv.app.toggleSlideshow(); this.refresh(); }, }); }, toggleLoop: () => { return new MenuOptionToggle({ ...sharedOptions, label: "Loop", checked: helpers.args.location.hash.get("slideshow") == "loop", icon: "mat:replay_circle_filled", requiresImage: true, hideIfUnavailable: true, onclick: () => { ppixiv.app.loopSlideshow(); this.refresh(); }, }); }, linkedTabs: () => { let widget = new MenuOptionToggleSetting({ container: optionBox, label: "Linked tabs", setting: "linked_tabs_enabled", icon: "mat:link", }); new MenuOptionButton({ container: widget.root.querySelector(".checkbox"), containerPosition: "beforebegin", icon: "mat:settings", classes: ["small-font"], onclick: (e) => { e.stopPropagation(); new SettingsPageDialog({ settingsPage: "linkedTabs" }); this.parent.hide(); return true; }, }); return widget; }, imageEditing: () => { return new MenuOptionToggleSetting({ ...sharedOptions, label: "Image editing", icon: "mat:brush", setting: "image_editing", requiresImage: true, onclick: () => { /\x2f When editing is turned off, clear the editing mode too. let enabled = ppixiv.settings.get("image_editing"); if(!enabled) ppixiv.settings.set("image_editing_mode", null); }, }); }, openSettings: () => { return new MenuOptionButton({ ...sharedOptions, label: "Settings", icon: "mat:settings", onclick: () => { new SettingsDialog(); this.parent.hide(); } }); }, exit: () => { return new MenuOptionButton({ ...sharedOptions, label: "Return to Pixiv", icon: "mat:logout", url: "#no-ppixiv", }); }, toggleTranslations: () => { let isEnabled = () => { if(this.mediaId == null || this.mediaInfo == null) return false; /\x2f Disable this for animations. return this.mediaInfo.illustType != 2; }; let widget = new MenuOptionToggleImageTranslation({ ...sharedOptions, requires: () => isEnabled(), mediaId: this.mediaId, label: "Translate", icon: "mat:translate", }); new MenuOptionButton({ container: widget.root.querySelector(".checkbox"), containerPosition: "beforebegin", icon: "mat:settings", classes: ["small-font"], onclick: (e) => { e.stopPropagation(); /\x2f Don't show the per-image options dialog if translations aren't supported for /\x2f this image. if(!isEnabled()) return; new SettingsPageDialog({ settingsPage: "translationOverride" }); this.parent.hide(); return true; }, }); return widget; }, }; let screenName = ppixiv.app.getDisplayedScreen({ name: true }) this._menuOptions = []; if(!ppixiv.native) { this._menuOptions.push(menuOptions.similarIllustrations()); this._menuOptions.push(menuOptions.similarArtists()); if(this.showExtra) this._menuOptions.push(menuOptions.similarBookmarks()); this._menuOptions.push(menuOptions.downloadImage()); this._menuOptions.push(menuOptions.downloadManga()); this._menuOptions.push(menuOptions.downloadVideo()); this._menuOptions.push(menuOptions.editMutes()); /\x2f This is hidden by default since it's special-purpose: it shares the image URL, not the /\x2f page URL, which is used for special-purpose iOS shortcuts stuff that probably nobody else /\x2f cares about. if(ppixiv.settings.get("show_share")) this._menuOptions.push(menuOptions.shareImage()); } else { this._menuOptions.push(menuOptions.similarLocalImages()); } if(screenName == "illust" && ppixiv.imageTranslations.supported) this._menuOptions.push(menuOptions.toggleTranslations()); if(ppixiv.sendImage.enabled) { this._menuOptions.push(menuOptions.sendToTab()); this._menuOptions.push(menuOptions.linkedTabs()); } /\x2f These are in the top-level menu on mobile. Don't show these if we're on the search /\x2f view either, since they want to actually be on the illust view, not hovering a thumbnail. if(screenName == "illust") { this._menuOptions.push(menuOptions.toggleSlideshow()); this._menuOptions.push(menuOptions.toggleLoop()); } if(!ppixiv.mobile) this._menuOptions.push(menuOptions.imageEditing()); if(ppixiv.native) { this._menuOptions.push(menuOptions.indexFolderForSimilaritySearch()); this._menuOptions.push(menuOptions.toggleUpscaling()); } if(this.showExtra || ppixiv.native) this._menuOptions.push(menuOptions.refreshImage()); /\x2f Add settings for mobile. On desktop, this is available in a bunch of other /\x2f higher-profile places. if(ppixiv.mobile) this._menuOptions.push(menuOptions.openSettings()); if(!ppixiv.native && !ppixiv.mobile) this._menuOptions.push(menuOptions.exit()); window.vviewHooks?.dropdownMenuOptions?.({ moreOptionsDropdown: this, sharedOptions }); } setUserId(userId) { this._userId = userId; this.refresh(); } visibilityChanged() { if(this.visible) this.refresh(); } /\x2f If a user ID was specified explicitly, return it. Otherwise, return mediaId's user if we know it. get _effectiveUserId() { return this._userId ?? this.mediaInfo?.userId; } async refreshInternal({ mediaId, mediaInfo }) { if(!this.visible) return; this._createMenuOptions(); this.mediaInfo = mediaInfo; for(let option of this._menuOptions) { let enable = true; /\x2f Enable or disable buttons that require an image. if(option.options.requiresImage && mediaId == null) enable = false; if(option.options.requiresUser && this._effectiveUserId == null) enable = false; if(option.options.requires && !option.options.requires({mediaId, userId: this._effectiveUserId})) enable = false; if(enable && option.options.available) enable = option.options.available(); option.enabled = enable; /\x2f Some options are hidden when they're unavailable, because they clutter /\x2f the menu too much. if(option.options.hideIfUnavailable) option.root.hidden = !enable; } } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/more-options-dropdown.js `), "/vview/widgets/mutes.js": loadBlob("application/javascript", `/\x2f The "Muted Users" and "Muted Tags" settings pages. import Widget from '/vview/widgets/widget.js'; import { TextPrompt } from '/vview/widgets/prompts.js'; import DialogWidget from '/vview/widgets/dialog.js'; import { helpers } from '/vview/misc/helpers.js'; export class EditMutedTagsWidget extends Widget { constructor({ muteType, /\x2f "tags" or "users" ...options}) { super({...options, template: \`
Users can be muted from their user page, or by right-clicking an image and clicking \${ helpers.createIcon("settings") }. \${ helpers.createBoxLink({label: "Note", icon: "warning", classes: ["mute-warning-button"] }) }
You can mute any number of tags and users.

However, since you don't have Premium, mutes will only be saved in your browser and can't be saved to your Pixiv account. They will be lost if you change browsers or clear site data.
\${ helpers.createBoxLink({label: "Add", icon: "add", classes: ["add-muted-tag"] }) }
\`}); this._muteType = muteType; this.root.querySelector(".add-muted-tag-box").hidden = muteType != "tag"; this.root.querySelector(".add-muted-user-box").hidden = muteType != "user"; this.root.querySelector(".add-muted-tag").addEventListener("click", this._clickedAddMutedTag); this.root.querySelector(".mute-warning-button").addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); let muteWarning = this.root.querySelector(".mute-warning"); muteWarning.hidden = !muteWarning.hidden; }); /\x2f Hide the warning for non-premium users if the user does have premium. this.root.querySelector(".non-premium-mute-warning").hidden = ppixiv.pixivInfo.premium; } visibilityChanged() { super.visibilityChanged(); if(this.visible) { this.root.querySelector(".mute-warning").hidden = true; this.refresh(); } /\x2f Clear the username cache when we're hidden, so we'll re-request it the next time /\x2f we're viewed. if(!this.visible) this._clearMutedUserIdCache(); } refresh = async() => { if(!this.visible) return; if(this._muteType == "tag") await this._refreshForTags(); else await this._refrehsForUsers(); } createEntry() { return this.createTemplate({name: "muted-tag-entry", html: \` \`}); } _refreshForTags = async() => { /\x2f Do a batch lookup of muted tag translations. let tagsToTranslate = [...ppixiv.muting.pixivMutedTags]; for(let mute of ppixiv.muting.extraMutes) { if(mute.type == "tag") tagsToTranslate.push(mute.value); } let translatedTags = await ppixiv.tagTranslations.getTranslations(tagsToTranslate); let createMutedTagEntry = (tag, tagListContainer) => { let entry = this.createEntry(); entry.dataset.tag = tag; let label = tag; let tagTranslation = translatedTags[tag]; if(tagTranslation) label = \`\${tagTranslation} (\${tag})\`; entry.querySelector(".tag-name").innerText = label; tagListContainer.appendChild(entry); return entry; }; let mutedTagList = this.root.querySelector(".mute-list"); helpers.html.removeElements(mutedTagList); for(let {type, value: tag} of ppixiv.muting.extraMutes) { if(type != "tag") continue; let entry = createMutedTagEntry(tag, mutedTagList); entry.querySelector(".remove-mute").addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); ppixiv.muting.removeExtraMute(tag, {type: "tag"}); this.refresh(); }); } for(let tag of ppixiv.muting.pixivMutedTags) { let entry = createMutedTagEntry(tag, mutedTagList); entry.querySelector(".remove-mute").addEventListener("click", async (e) => { e.preventDefault(); e.stopPropagation(); await ppixiv.muting.removePixivMute(tag, {type: "tag"}); this.refresh(); }); } } _refrehsForUsers = async() => { let createMutedTagEntry = (userId, username, tagListContainer) => { let entry = this.createEntry(); entry.dataset.userId = userId; entry.querySelector(".tag-name").innerText = username; tagListContainer.appendChild(entry); return entry; }; let mutedUserList = this.root.querySelector(".mute-list"); helpers.html.removeElements(mutedUserList); for(let {type, value: userId, label: username} of ppixiv.muting.extraMutes) { if(type != "user") continue; let entry = createMutedTagEntry(userId, username, mutedUserList); entry.querySelector(".remove-mute").addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); ppixiv.muting.removeExtraMute(userId, {type: "user"}); this.refresh(); }); } /\x2f We already know the muted user IDs, but we need to load the usernames for display. /\x2f If we don't have this yet, start the load and refresh once we have it. let userIdToUsername = this._cachedMutedUserIdToUsername; if(userIdToUsername == null) { this._getMutedUserIdToUsername().then(() => { console.log("Refreshing after muted user load"); this.refresh(); }); } else { /\x2f Now that we have usernames, Sort Pixiv mutes by username. let mutes = ppixiv.muting.pixivMutedUserIds; mutes.sort((lhs, rhs) => { lhs = userIdToUsername[lhs] || ""; rhs = userIdToUsername[rhs] || ""; return lhs.localeCompare(rhs); }); for(let userId of mutes) { let entry = createMutedTagEntry(userId, userIdToUsername[userId], mutedUserList); entry.querySelector(".remove-mute").addEventListener("click", async (e) => { e.preventDefault(); e.stopPropagation(); await ppixiv.muting.removePixivMute(userId, {type: "user"}); this.refresh(); }); } } } _clearMutedUserIdCache() { this._cachedMutedUserIdToUsername = null; } /\x2f Return a dictionary of muted user IDs to usernames. _getMutedUserIdToUsername() { /\x2f If this completed previously, just return the cached results. if(this._cachedMutedUserIdToUsername) return this._cachedMutedUserIdToUsername; /\x2f If this is already running, return the existing promise and don't start another. if(this._mutedUserIdToUsernamePromise) return this._mutedUserIdToUsernamePromise; let promise = this._getMutedUserIdToUsernameInner(); this._mutedUserIdToUsernamePromise = promise; this._mutedUserIdToUsernamePromise.finally(() => { /\x2f Clear _mutedUserIdToUsernamePromise when it finishes. if(this._mutedUserIdToUsernamePromise == promise) this._mutedUserIdToUsernamePromise = null; }); return this._mutedUserIdToUsernamePromise; } async _getMutedUserIdToUsernameInner() { /\x2f Users muted with Pixiv. We already have the list, but we need to make an API /\x2f request to get usernames to actually display. let result = await helpers.pixivRequest.get("/ajax/mute/items", { context: "setting" }); if(result.error) { ppixiv.message.show(result.message); this._cachedMutedUserIdToUsername = {}; return this._cachedMutedUserIdToUsername; } let userIdToUsername = {}; for(let item of result.body.mute_items) { /\x2f We only care about user mutes here. if(item.type == "user") userIdToUsername[item.value] = item.label; } this._cachedMutedUserIdToUsername = userIdToUsername; return this._cachedMutedUserIdToUsername; } /\x2f Add to our muted tag list. _clickedAddMutedTag = async (e) => { e.preventDefault(); e.stopPropagation(); let prompt = new TextPrompt({ title: "Tag to mute:" }); let tag = await prompt.result; if(tag == null || tag == "") return; /\x2f cancelled /\x2f If the user has premium, use the regular Pixiv mute list. Otherwise, add the tag /\x2f to extra mutes. We never add anything to the Pixiv mute list for non-premium users, /\x2f since it's limited to only one entry. if(ppixiv.pixivInfo.premium) await ppixiv.muting.addPixivMute(tag, {type: "tag"}); else await ppixiv.muting.addExtraMute(tag, tag, {type: "tag"}); this.refresh(); }; } /\x2f A popup for editing mutes related for a post (the user and the post's tags). export class MutedTagsForPostDialog extends DialogWidget { constructor({ mediaId, userId, ...options}) { super({...options, classes: "muted-tags-popup", header: "Edit mutes", template: \`
\${ helpers.createBoxLink({label: "Note", icon: "warning", classes: ["mute-warning-button", "clickable"] }) }
\`}); this._mediaId = mediaId; this._userId = userId; this.root.querySelector(".close-button").addEventListener("click", (e) => { this.shutdown(); }, { signal: this.shutdownSignal }); this.root.querySelector(".mute-warning-button").addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); let muteWarning = this.root.querySelector(".mute-warning"); muteWarning.hidden = !muteWarning.hidden; }); /\x2f Hide the warning for non-premium users if the user does have premium. this.root.querySelector(".non-premium-mute-warning").hidden = ppixiv.pixivInfo.premium; this.refresh(); } refresh = async() => { if(this._mediaId != null) { /\x2f We have a media ID. Load its info to get the tag list, and use the user ID and /\x2f username from it. let mediaInfo = await ppixiv.mediaCache.getMediaInfo(this._mediaId, { full: false }); await this._refreshForData(mediaInfo.tagList, mediaInfo.userId, mediaInfo.userName); } else { /\x2f We only have a user ID, so look up the user to get the username. Don't display /\x2f any tags. let userInfo = await ppixiv.userCache.getUserInfo(this._userId); await this._refreshForData([], this._userId, userInfo.name); } } async _refreshForData(tags, userId, username) { /\x2f Do a batch lookup of muted tag translations. let translatedTags = await ppixiv.tagTranslations.getTranslations(tags); let createEntry = (label, isMuted) => { let entry = this.createTemplate({name: "muted-tag-or-user-entry", html: \`
\${ helpers.createBoxLink({label: "Mute", classes: ["toggle-mute"] }) }
\`}); helpers.html.setClass(entry, "muted", isMuted); entry.querySelector(".toggle-mute .label").innerText = isMuted? "Muted":"Mute"; entry.querySelector(".tag-name").innerText = label; mutedList.appendChild(entry); return entry; }; let mutedList = this.root.querySelector(".post-mute-list"); helpers.html.removeElements(mutedList); /\x2f Add an entry for the user. { let isMuted = ppixiv.muting.isUserIdMuted(userId); let entry = createEntry(\`User: \${username}\`, isMuted); entry.querySelector(".toggle-mute").addEventListener("click", async (e) => { if(isMuted) { ppixiv.muting.removeExtraMute(userId, {type: "user"}); await ppixiv.muting.removePixivMute(userId, {type: "user"}); } else { await ppixiv.muting.addMute(userId, username, {type: "user"}); } this.refresh(); }); } /\x2f Add each tag on the image. for(let tag of tags) { let isMuted = ppixiv.muting.anyTagMuted([tag]); let label = tag; let tagTranslation = translatedTags[tag]; if(tagTranslation) label = \`\${tagTranslation} (\${tag})\`; let entry = createEntry(label, isMuted); entry.querySelector(".toggle-mute").addEventListener("click", async (e) => { if(isMuted) { ppixiv.muting.removeExtraMute(tag, {type: "tag"}); await ppixiv.muting.removePixivMute(tag, {type: "tag"}); } else { await ppixiv.muting.addMute(tag, tag, {type: "tag"}); } this.refresh(); }); } } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/mutes.js `), "/vview/widgets/prompts.js": loadBlob("application/javascript", `/\x2f A popup for inputting text. import DialogWidget from '/vview/widgets/dialog.js'; import { helpers } from '/vview/misc/helpers.js'; export class TextPrompt extends DialogWidget { static async prompt(options) { let prompt = new this(options); return await prompt.result; } constructor({ title, value="", ...options }={}) { super({...options, dialogClass: "text-entry-popup", small: true, header: title, template: \`
\${ helpers.createIcon("mat:check") }
\`}); this.result = new Promise((completed, cancelled) => { this._completed = completed; }); this.input = this.root.querySelector(".editor"); /\x2f Set text by creating a node manually, since textContent won't create a node if value is "". this.input.appendChild(document.createTextNode(value)); this.root.querySelector(".submit-button").addEventListener("click", this.submit); } _handleKeydown = (e) => { if(super._handleKeydown(e)) return true; /\x2f The escape key is handled by DialogWidget. if(e.key == "Enter") { this.submit(); return true; } return false; } visibilityChanged() { super.visibilityChanged(); if(this.visible) { window.addEventListener("keydown", this.onkeydown, { signal: this.visibilityAbort.signal }); /\x2f Focus when we become visible. this.input.focus(); /\x2f Move the cursor to the end. let size = this.input.firstChild.length; window.getSelection().setBaseAndExtent(this.input.firstChild, size, this.input.firstChild, size); } else { /\x2f If we didn't complete by now, cancel. this._completed(null); } } /\x2f Close the popup and call the completion callback with the result. submit = () => { let result = this.input.textContent; this._completed(result); this.visible = false; } } export class ConfirmPrompt extends DialogWidget { static async prompt(options) { let prompt = new this(options); return await prompt.result; } constructor({ header, text, ...options }={}) { super({...options, dialogClass: "confirm-dialog", allowClose: false, small: true, header, template: \`
\${helpers.createBoxLink({ label: "Yes", icon: "image", classes: ["yes"], })} \${helpers.createBoxLink({ label: "No", icon: "image", classes: ["no"], })}
\`}); if(text) { let textNode = this.root.querySelector(".text"); textNode.innerText = text; textNode.hidden = false; } this.result = new Promise((completed, cancelled) => { this._completed = completed; }); this.root.querySelector(".yes").addEventListener("click", () => this.submit(true), { signal: this.shutdownSignal }); this.root.querySelector(".no").addEventListener("click", () => this.submit(false), { signal: this.shutdownSignal }); } onkeydown = (e) => { if(e.key == "Escape") { e.preventDefault(); e.stopPropagation(); this.submit(false); } if(e.key == "Enter") { e.preventDefault(); e.stopPropagation(); this.submit(true); } } visibilityChanged() { super.visibilityChanged(); if(this.visible) { window.addEventListener("keydown", this.onkeydown, { signal: this.visibilityAbort.signal }); } else { /\x2f If we didn't complete by now, cancel. this._completed(null); } } /\x2f Close the popup and call the completion callback with the result. submit = (result) => { this._completed(result); this.visible = false; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/prompts.js `), "/vview/widgets/settings-widgets.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; import { MenuOptionButton, MenuOptionRow, MenuOptionOptionsSetting, MenuOptionsThumbnailSizeSlider } from '/vview/widgets/menu-option.js'; import { MenuOptionSliderSetting, MenuOptionToggleSetting } from '/vview/widgets/menu-option.js'; import { createTranslationSettingsWidgets } from '/vview/misc/image-translation.js'; import { EditMutedTagsWidget } from '/vview/widgets/mutes.js'; import { LinkTabsPopup } from '/vview/misc/send-image.js'; import DialogWidget from '/vview/widgets/dialog.js'; import PointerListener from '/vview/actors/pointer-listener.js'; import { helpers } from '/vview/misc/helpers.js'; import WhatsNew from '/vview/widgets/whats-new.js'; import { ConfirmPrompt } from '/vview/widgets/prompts.js'; import LocalAPI from '/vview/misc/local-api.js'; function createSettingsWidget({ globalOptions }) { /\x2f Each settings widget. Doing it this way lets us move widgets around in the /\x2f menu without moving big blocks of code around. return { thumbnailSize: () => { let button = new MenuOptionButton({ ...globalOptions, label: "Thumbnail size", }); new MenuOptionsThumbnailSizeSlider({ container: button.querySelector(".widget-box"), setting: "thumbnail-size", classes: ["size-slider"], min: 0, max: 7, }); }, disabledByDefault: () => { return new MenuOptionToggleSetting({ ...globalOptions, label: "Disabled by default", setting: "disabled-by-default", explanationEnabled: "Go to Pixiv by default.", explanationDisabled: "Go here by default.", }); }, noHideCursor: () => { return new MenuOptionToggleSetting({ ...globalOptions, label: "Hide cursor", setting: "no-hide-cursor", invertDisplay: true, explanationEnabled: "Hide the cursor while the mouse isn't moving.", explanationDisabled: "Don't hide the cursor while the mouse isn't moving.", }); }, invertPopupHotkey: () => { return new MenuOptionToggleSetting({ ...globalOptions, label: "Shift-right-click to show the popup menu", setting: "invert-popup-hotkey", explanationEnabled: "Shift-right-click to open the popup menu", explanationDisabled: "Right click opens the popup menu", }); }, ctrlOpensPopup: () => { return new MenuOptionToggleSetting({ ...globalOptions, label: "Hold ctrl to show the popup menu", setting: "ctrl_opens_popup", explanationEnabled: "Pressing Ctrl shows the popup menu (for laptops)", }); }, uiOnHover: () => { new MenuOptionToggleSetting({ ...globalOptions, label: "Hover to show search box", setting: "ui-on-hover", explanationEnabled: "Only show the search box when hovering over it", explanationDisabled: "Always show the search box", }); }, invertScrolling: () => { return new MenuOptionToggleSetting({ ...globalOptions, label: "Invert image panning", setting: "invert-scrolling", explanationEnabled: "Dragging down moves the image down", explanationDisabled: "Dragging down moves the image up", }); }, disableTranslations: () => { return new MenuOptionToggleSetting({ ...globalOptions, label: "Show tag translations", setting: "disable-translations", invertDisplay: true, }); }, thumbnailStyle: () => { return new MenuOptionOptionsSetting({ ...globalOptions, setting: "thumbnail_style", label: "Thumbnail style", values: { aspect: "Aspect", square: "Square", }, }); }, disableThumbnailPanning: () => { return new MenuOptionToggleSetting({ ...globalOptions, label: "Pan thumbnails while hovering over them", setting: "disable_thumbnail_panning", invertDisplay: true, shouldBeVisible: () => ppixiv.settings.get("thumbnail_style") != "aspect", }); }, disableThumbnailZooming: () => { return new MenuOptionToggleSetting({ ...globalOptions, label: "Zoom out thumbnails while hovering over them", setting: "disable_thumbnail_zooming", invertDisplay: true, shouldBeVisible: () => ppixiv.settings.get("thumbnail_style") != "aspect", }); }, enableTransitions: () => { return new MenuOptionToggleSetting({ ...globalOptions, label: "Use transitions", setting: "animations_enabled", }); }, bookmarkPrivatelyByDefault: () => { return new MenuOptionToggleSetting({ ...globalOptions, label: "Bookmark and follow privately", setting: "bookmark_privately_by_default", explanationDisabled: ppixiv.mobile? null: "Pressing Ctrl-B will bookmark publically", explanationEnabled: ppixiv.mobile? null: "Pressing Ctrl-B will bookmark privately", }); }, limitSlideshowFramerate: () => { return new MenuOptionToggleSetting({ ...globalOptions, label: "Limit slideshows to 60 FPS", setting: "slideshow_framerate", onValue: 60, offValue: null, }); }, importExtraData: () => { let widget = new MenuOptionRow({ ...globalOptions, label: "Image edits", }); new MenuOptionButton({ icon: "file_upload", label: "Import", container: widget.root, onclick: () => ppixiv.extraImageData.import(), }); new MenuOptionButton({ icon: "file_download", label: "Export", container: widget.root, onclick: () => ppixiv.extraImageData.export(), }); return widget; }, stageSlideshow: () => { let widget = new MenuOptionRow({ ...globalOptions, label: "Bookmark slideshow", }); new MenuOptionButton({ icon: "wallpaper", label: "Go", container: widget.root, onclick: () => { /\x2f Close the settings dialog. globalOptions.closeSettings(); SlideshowStagingDialog.show(); }, }); return widget; }, quickView: () => { return new MenuOptionToggleSetting({ ...globalOptions, label: "Quick view", setting: "quick_view", explanationEnabled: "Navigate to images immediately when the mouse button is pressed", check: () => { /\x2f Only enable changing this option when using a mouse. It has no effect /\x2f on touchpads. if(PointerListener.pointerType == "mouse") return true; ppixiv.message.show("Quick View is only supported when using a mouse."); return false; }, }); }, autoPan: () => { return new MenuOptionToggleSetting({ ...globalOptions, label: "Pan images", setting: "auto_pan", explanationEnabled: "Pan images while viewing them (drag the image to stop)", }); }, autoPanSpeed: () => { let button = new MenuOptionButton({ ...globalOptions, label: "Time per image", getLabel: () => "Pan duration", explanationEnabled: (value) => { let seconds = ppixiv.settings.get("auto_pan_duration");; return \`\${seconds} \${seconds != 1? "seconds":"second"}\`; }, }); new MenuOptionSliderSetting({ container: button.querySelector(".widget-box"), setting: "auto_pan_duration", list: [1, 2, 3, 5, 10, 15, 20, 30, 45, 60], classes: ["size-slider"], /\x2f Refresh the label when the value changes. refresh: () => button.refresh(), }); return button; }, slideshowSpeed: () => { let button = new MenuOptionButton({ ...globalOptions, label: "Time per image", getLabel: () => "Slideshow duration", explanationEnabled: (value) => { let seconds = ppixiv.settings.get("slideshow_duration");; return \`\${seconds} \${seconds != 1? "seconds":"second"}\`; }, }); new MenuOptionSliderSetting({ container: button.querySelector(".widget-box"), setting: "slideshow_duration", list: [1, 2, 3, 5, 10, 15, 20, 30, 45, 60, 90, 120, 180], classes: ["size-slider"], /\x2f Refresh the label when the value changes. refresh: () => { button.refresh(); }, }); }, slideshowDefaultAnimation: () => { return new MenuOptionOptionsSetting({ ...globalOptions, setting: "slideshow_default", label: "Slideshow mode", values: { pan: "Pan", contain: "Fade", }, explanation: (value) => { switch(value) { case "pan": return "Pan the image left-to-right or top-to-bottom"; case "contain": return "Fade in and out without panning"; } }, }); }, slideshowSkipsManga: () => { return new MenuOptionToggleSetting({ ...globalOptions, label: "Slideshow skips manga pages", setting: "slideshow_skips_manga", explanationEnabled: "Slideshow mode will only show the first page.", explanationDisabled: "Slideshow mode will show all pages.", }); }, displayMode: () => { return new MenuOptionOptionsSetting({ ...globalOptions, setting: "display_mode", label: "Display mode", values: { auto: "Automatic", normal: "Fill the screen", notch: "Rounded display", safe: "Avoid the status bar", }, }); }, expandMangaPosts: () => { return new MenuOptionToggleSetting({ ...globalOptions, label: "Expand manga posts", setting: "expand_manga_thumbnails", }); }, viewMode: () => { new MenuOptionToggleSetting({ ...globalOptions, label: "Return to the top when changing images", setting: "view_mode", onValue: "manga", offValue: "illust", }); }, pixivCdn: () => { let values = { }; for(let [setting, {name}] of Object.entries(helpers.pixiv.pixivImageHosts)) values[setting] = name; return new MenuOptionOptionsSetting({ ...globalOptions, setting: "pixiv_cdn", label: "Pixiv image host", values, }); }, openPixiv: () => { return new MenuOptionButton({ ...globalOptions, label: "Open Pixiv", onclick: () => { /\x2f On mobile, open Pixiv in a new window. In Safari this will give a new /\x2f tab where browser back will close the tab and return here. This keeps /\x2f it from becoming a browser navigation, so we don't enable back/forward /\x2f gestures for the tab if possible. let url = new URL("#no-ppixiv", window.location); window.open(url); }, }); }, linkTabs: () => { let widget = new LinkTabsPopup({ ...globalOptions, }); /\x2f Tell the widget when it's no longer visible. globalOptions.pageRemovedSignal.addEventListener("abort", () => { widget.visible = false; }); return widget; }, enableLinkedTabs: () => { return new MenuOptionToggleSetting({ ...globalOptions, label: "Enabled", setting: "linkedTabs_enabled", }); }, unlinkAllTabs: () => { return new MenuOptionButton({ ...globalOptions, label: "Unlink all tabs", onclick: () => { ppixiv.settings.set("linked_tabs", []); }, }); }, mutedTags: () => { let widget = new EditMutedTagsWidget({ muteType: "tag", ...globalOptions, }); /\x2f Tell the widget when it's no longer visible. globalOptions.pageRemovedSignal.addEventListener("abort", () => { widget.visible = false; }); return widget; }, mutedUsers: () => { let widget = new EditMutedTagsWidget({ muteType: "user", ...globalOptions, }); /\x2f Tell the widget when it's no longer visible. globalOptions.pageRemovedSignal.addEventListener("abort", () => { widget.visible = false; }); return widget; }, whatsNew: async() => { let widget = new WhatsNew({ ...globalOptions, }); globalOptions.pageRemovedSignal.addEventListener("abort", () => { widget.visible = false; }); return widget; }, nativeLogin: () => { return new MenuOptionButton({ ...globalOptions, label: LocalAPI.localInfo.logged_in? "Log out":"Login", onclick: async() => { let { logged_in } = LocalAPI.localInfo; if(!logged_in) { LocalAPI.redirectToLogin(); return; } let prompt = new ConfirmPrompt({ header: "Log out?" }); let result = await prompt.result; console.log(result); if(result) LocalAPI.logout(); }, }); }, }; } let pageTitles = { thumbnail: "Thumbnail options", image:"Image viewing", tagMuting: "Muted tags", userMuting: "Muted users", linkedTabs: "Linked tabs", translation: "Translation", translationOverride: "Translation", other: "Other", whatsNew: "What's New", }; export class SettingsDialog extends DialogWidget { constructor({showPage="thumbnail", ...options}={}) { super({ ...options, dialogClass: "settings-dialog", classes: ["settings-window"], header: "Settings", template: \`
\` }); this.phone = helpers.other.isPhone(); helpers.html.setClass(this.root, "phone", this.phone); this._pageButtons = {}; /\x2f If we're using a phone UI, we're showing items by opening a separate dialog. The /\x2f page contents block will be empty, so hide it to let the options center. this.root.querySelector(".items").hidden = this.phone; this.addPages(); /\x2f If we're not on the phone UI, show the default page. showPage ??= "thumbnail"; if(!this.phone) this.showPage(showPage); } addPages() { this._createPageButton("thumbnail"); this._createPageButton("image"); if(!ppixiv.native) { this._createPageButton("tagMuting"); this._createPageButton("userMuting"); } if(ppixiv.imageTranslations.supported) this._createPageButton("translation"); if(ppixiv.sendImage.enabled) this._createPageButton("linkedTabs"); this._createPageButton("other"); this._createPageButton("whatsNew"); } _createPageButton(name) { let pageButton = this.createTemplate({ html: helpers.createBoxLink({ label: pageTitles[name], classes: ["settings-page-button"], }), }); pageButton.dataset.page = name; pageButton.addEventListener("click", (e) => { this.showPage(name); }); this.root.querySelector(".sections").appendChild(pageButton); this._pageButtons[name] = pageButton; /\x2f Mark all buttons as selected on the phone UI so they're always highlighted. if(this.phone) helpers.html.setClass(pageButton, "selected", true); return pageButton; } showPage(settingsPage) { /\x2f If we're on a phone, create a dialog to show the page. if(this.phone) { this._hideAndShowPageDialog(settingsPage); return; } /\x2f Create the page in our items container. if(this._visiblePageName == settingsPage) return; /\x2f Remove the widget page or dialog if it still exists. if(this._pageWidget != null) this._pageWidget.shutdown(); console.assert(this._pageWidget == null); this._visiblePageName = settingsPage; if(settingsPage == null) return; this._pageWidget = this._createPage(settingsPage); helpers.html.setClass(this._pageButtons[settingsPage], "selected", true); if(!this.phone) this.header = pageTitles[settingsPage]; this._pageWidget.shutdownSignal.addEventListener("abort", () => { this._pageWidget = null; helpers.html.setClass(this._pageButtons[settingsPage], "selected", false); }); } /\x2f Hide ourself and show a settings page in a dialog. When the page closes, open /\x2f ourselves again. async _hideAndShowPageDialog(settingsPage) { this.visible = false; /\x2f If this triggered a transition, wait for it to finish. Overlapping the opening /\x2f and closing would be fine, but it's hard to overlap them the other way when the /\x2f page is closing, and it looks better to have the transition look the same both /\x2f ways. await this.visibilityChangePromise(); let dialog = new SettingsPageDialog({ settingsPage }); dialog.shutdownSignal.addEventListener("abort", () => { new SettingsDialog(); }); } _createPage(settingsPage) { let pageWidget = new Widget({ container: this.root.querySelector(".items"), template: \`
\` }); SettingsDialog._fillPage({ settingsPage, pageWidget, pageContainer: pageWidget.root, }); return pageWidget; } static _fillPage({ settingsPage, pageWidget, pageContainer }) { /\x2f Set settings-list if this page is a list of options, like the thumbnail options page. /\x2f This class enables styling for these lists. If it's another type of settings page /\x2f with its own styling, this is disabled. let isSettingsList = settingsPage != "tagMuting" && settingsPage != "userMuting"; if(isSettingsList) pageContainer.classList.add("settings-list"); /\x2f Options that we pass to all menu options: let globalOptions = { classes: ["settings-row"], container: pageContainer, pageRemovedSignal: pageWidget.shutdownSignal, /\x2f Settings widgets can call this to close the window. closeSettings: () => { this.visible = false; }, }; /\x2f This gives us a dictionary of functions we can use to create each settings widget. let settingsWidgets = createSettingsWidget({ globalOptions }); let pages = { thumbnail: () => { settingsWidgets.thumbnailSize(); settingsWidgets.thumbnailStyle(); if(!ppixiv.mobile) { settingsWidgets.disableThumbnailPanning(); settingsWidgets.disableThumbnailZooming(); settingsWidgets.quickView(); settingsWidgets.uiOnHover(); } if(!ppixiv.native) settingsWidgets.expandMangaPosts(); }, image: () => { settingsWidgets.autoPan(); settingsWidgets.autoPanSpeed(); settingsWidgets.slideshowSpeed(); settingsWidgets.slideshowDefaultAnimation(); if(!ppixiv.native) /\x2f native mode doesn't support manga pages settingsWidgets.slideshowSkipsManga(); if(ppixiv.mobile) settingsWidgets.displayMode(); settingsWidgets.viewMode(); if(!ppixiv.mobile) { settingsWidgets.invertScrolling(); settingsWidgets.noHideCursor(); } }, tagMuting: () => { settingsWidgets.mutedTags(); }, userMuting: () => { settingsWidgets.mutedUsers(); }, linkedTabs: () => { settingsWidgets.linkTabs(); settingsWidgets.unlinkAllTabs(); }, /\x2f ImageTranslations handles these settings. translationOverride is the settings override version /\x2f when we're viewing an image. translation: () => createTranslationSettingsWidgets({ globalOptions, editOverrides: false }), translationOverride: () => createTranslationSettingsWidgets({ globalOptions, editOverrides: true }), other: () => { if(ppixiv.native && !LocalAPI.localInfo.local) settingsWidgets.nativeLogin(); settingsWidgets.disableTranslations(); if(!ppixiv.native && !ppixiv.mobile) settingsWidgets.disabledByDefault(); if(!ppixiv.mobile) { /\x2f Firefox's contextmenu behavior is broken, so hide this option. if(navigator.userAgent.indexOf("Firefox/") == -1) settingsWidgets.invertPopupHotkey(); settingsWidgets.ctrlOpensPopup(); settingsWidgets.enableTransitions(); } settingsWidgets.bookmarkPrivatelyByDefault(); if(!ppixiv.mobile) settingsWidgets.limitSlideshowFramerate(); if(!ppixiv.native) settingsWidgets.pixivCdn(); if(!ppixiv.native && ppixiv.mobile) settingsWidgets.openPixiv(); /\x2f Chrome supports showOpenFilePicker, but Firefox doesn't. That API has been around in /\x2f Chrome for a year and a half, so I haven't implemented an alternative for Firefox. if(!ppixiv.native && window.showOpenFilePicker != null) settingsWidgets.importExtraData(); /\x2f Slideshow staging isn't useful on mobile with Pixiv since we can't run ourself as /\x2f a PWA. if(ppixiv.native || !ppixiv.mobile) settingsWidgets.stageSlideshow(); }, whatsNew: () => { settingsWidgets.whatsNew(); }, }; let createPage = pages[settingsPage]; if(createPage == null) { console.error(\`Invalid settings page: \${settingsPage}\`); return; } createPage(); /\x2f Add allow-wrap to all top-level box links that we just created, so the /\x2f settings menu scales better. Don't recurse into nested buttons. for(let boxLink of pageContainer.querySelectorAll(".settings-page > .box-link")) boxLink.classList.add("allow-wrap"); } }; /\x2f This is used when we're on the phone UI to show a single settings page. export class SettingsPageDialog extends DialogWidget { constructor({ settingsPage, ...options}={}) { super({ header: pageTitles[settingsPage], ...options, dialogClass: "settings-dialog-page", /\x2f This is a nested dialog and closing it goes back to settings, so show /\x2f a back button instead of a close button. backIcon: true, template: \`\` }); this._settingsContainer = this.querySelector(".scroll"); this._settingsContainer.classList.add("settings-page"); SettingsDialog._fillPage({ settingsPage, pageWidget: this, pageContainer: this._settingsContainer, }); } }; /\x2f Open a tab that can be used to bookmark a slideshow or save to home screen. /\x2f /\x2f This is made tricky by iOS limitations: it tries to save the URL the page was originally /\x2f loaded with, not the current URL. To work around this, we have to open the URL in a new /\x2f tab with the URL we want. /\x2f /\x2f On mobile this is meant to run the page in PWA mode. This won't work with Pixiv, since /\x2f user scripts don't work in that mode. Pixiv also has a manifest that'll force the URL /\x2f to the root, which also makes this not work. So, this is only really useful with vview, /\x2f but it can technically be used on desktop too. export class SlideshowStagingDialog extends DialogWidget { static show() { let slideshowArgs = ppixiv.app.slideshowURL; if(slideshowArgs == null) return; let url = slideshowArgs.toString(); /\x2f Open a tab for the dialog. Storing the dialog as window.slideshowStagingDialog /\x2f tells the dialog that it's a staging dialog without having to put anything in the /\x2f URL. window.slideshowStagingDialog = window.open(url); } constructor({...options}={}) { /\x2f Nobody can agree on terminology for this, and this text should be short and clear, /\x2f so tweak it based on the platform. let text = ppixiv.mobile? \` Add this page to your home screen for a slideshow of the current search. \`: \` Install this page as an app for a slideshow bookmark for the current search. \`; super({...options, showCloseButton: false, header: "Slideshow", template: \`
\${text}
\`}); this.url = helpers.args.location; document.title = window.opener.document.title; /\x2f If we see an appinstalled event when in Chrome, it stole the tab and turned it into /\x2f a standalone window. Clear slideshowStagingDialog and reload the page to turn into /\x2f the bookmarked window. Only do this on desktop, since this doesn't happen on mobile /\x2f so this reload is confusing. window.addEventListener("appinstalled", (e) => { if(ppixiv.mobile) return; window.opener.slideshowStagingDialog = null; window.location.reload(); }); /\x2f Close the tab if the dialog is closed. There's nothing left on the screen. /\x2f We're usually able to do this, since we opened the tab ourself. this.shutdownSignal.addEventListener("abort", () => window.close()); } visibilityChanged() { super.visibilityChanged(); if(!this.visible) { /\x2f If the URL is still pointing at the slideshow, back out to restore the original /\x2f URL. This is needed if we're exiting from the user clicking out of the dialog, /\x2f but don't do it if we're exiting from browser back. if(helpers.args.location.toString() == this.url.toString()) ppixiv.phistory.back(); } } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/settings-widgets.js `), "/vview/widgets/simple.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; import DragHandler from '/vview/misc/drag-handler.js'; import { helpers } from '/vview/misc/helpers.js'; export class CheckboxWidget extends Widget { constructor({ value=false, ...options}) { super({...options, template: \` \${ helpers.createIcon("", { classes: ["checkbox"] }) } \`}); this._checked = true; }; set checked(value) { if(this._checked == value) return; this._checked = value; this.refresh(); } get checked() { return this._checked; } async refresh() { this.root.innerText = this.checked? "check_box":"check_box_outline_blank"; } } /\x2f A minimal replacement for . HTML sliders are broken on iOS (they're /\x2f very hard to drag), and for some reason Edge's sliders are grey and always look disabled. export class SliderWidget extends Widget { constructor({ value=0, min=0, max=10, onchange=({value}) => { }, ...options }) { super({...options, template: \`
\`, }); this._value = value; this._min = min; this._max = max; this._onchange = onchange; this.dragger = new DragHandler({ parent: this, name: "slider", deferredStart: () => false, element: this.parent.root, ondragstart: (args) => this._ondrag(args), ondrag: (args) => this._ondrag(args), }); } get value() { return this._value; } get min() { return this._min; } get max() { return this._max; } set value(value) { if(this._value == value) return; this._value = value; this.refresh(); } set min(value) { if(this._min == value) return; this._min = value; this.refresh(); } set max(value) { if(this._max == value) return; this._max = value; this.refresh(); } _ondrag({event}) { let { left, right } = this.root.getBoundingClientRect(); let newValue = helpers.math.scaleClamp(event.clientX, left, right, this._min, this._max); newValue = Math.round(newValue); if(this._value == newValue) return true; this._value = newValue; this.refresh(); this._onchange({ value: this._value }); return true; } refresh() { let percent = helpers.math.scaleClamp(this._value, this._min, this._max, 0, 100); this.root.style.setProperty("--fill", \`\${percent}%\`); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/simple.js `), "/vview/widgets/tag-list-widget.js": loadBlob("application/javascript", `/\x2f A list of tags, with translations in popups where available. import Widget from '/vview/widgets/widget.js'; import { helpers } from '/vview/misc/helpers.js'; export default class TagListWidget extends Widget { constructor({...options}) { super({ ...options, template: \`
\` }); }; formatTagLink(tag) { return helpers.getArgsForTagSearch(tag, ppixiv.plocation); }; async set(mediaInfo) { this.mediaInfo = mediaInfo; this.refresh(); } async refresh() { if(this.mediaInfo == null) return; let tags = []; let showR18 = this.mediaInfo == 1; let showR18G = this.mediaInfo == 1; for(let tag of this.mediaInfo.tagList) { /\x2f If R-18 is in the list, remove it so we can add them in the position we want. /\x2f This should always match xRestrict, but we check both just to be safe. if(tag == "R-18") showR18 = true; else if(tag == "R-18G") showR18G = true; else tags.push({tag}); } /\x2f Add "AI" to the list. let showAI = this.mediaInfo.aiType == 2; if(showAI) tags.splice(0, 0, {ai: true}); if(showR18G) tags.splice(0, 0, {tag: "R-18G"}); else if(showR18) tags.splice(0, 0, {tag: "R-18"}); /\x2f Short circuit if the tag list isn't changing, since IndexedDB is really slow. if(this._currentTags != null && JSON.stringify(this._currentTags) == JSON.stringify(tags)) return; /\x2f Look up tag translations. let tagList = tags; let translatedTags = await ppixiv.tagTranslations.getTranslations(this.mediaInfo.tagList, "en"); /\x2f Stop if the tag list changed while we were reading tag translations. if(tagList != tags) return; this._currentTags = tags; /\x2f Remove any old tag list and create a new one. helpers.html.removeElements(this.root); for(let {tag, ai} of tagList) { if(ai) tag = "AI-generated"; let translatedTag = tag; if(translatedTags[tag]) translatedTag = translatedTags[tag]; let link = this.formatTagLink(tag); if(ai) link = null; let a = helpers.createBoxLink({ label: translatedTag, classes: ["tag-entry"], link, asElement: true, }); this.root.appendChild(a); a.dataset.tag = tag; } } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/tag-list-widget.js `), "/vview/widgets/tag-search-dropdown.js": loadBlob("application/javascript", `import widget from '/vview/widgets/widget.js'; import SavedSearchTags from '/vview/misc/saved-search-tags.js'; import DragHandler from '/vview/misc/drag-handler.js'; import { DropdownBoxOpener } from '/vview/widgets/dropdown.js'; import { ConfirmPrompt, TextPrompt } from '/vview/widgets/prompts.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f Handle showing the search history and tag edit dropdowns. export class TagSearchBoxWidget extends widget { constructor({...options}) { super({...options, template: \` \`}); this._inputElement = this.root.querySelector(".input-field-container > input"); this.querySelector(".edit-search-button").addEventListener("click", (e) => { this._dropdownOpener.visible = true; this._dropdownOpener.dropdown.editing = !this._dropdownOpener.dropdown.editing; }); this._dropdownOpener = new DropdownBoxOpener({ button: this._inputElement, createDropdown: ({...options}) => { let dropdown = new TagSearchDropdownWidget({ inputElement: this.root, parent: this, savedPosition: this._savedDropdownPosition, textPrompt: (args) => this.textPrompt(args), ...options, }); /\x2f Save the scroll position when the dropdown closes, so we can restore it the /\x2f next time we open it. dropdown.shutdownSignal.addEventListener("abort", () => { this._savedDropdownPosition = dropdown._saveSearchPosition(); }); return dropdown; }, shouldCloseForClick: (e) => { /\x2f Ignore clicks while we're showing a dialog. if(this._showingDialog) return false; /\x2f Ignore clicks inside our container. if(helpers.html.isAbove(this.root, e.target)) return false; return true; }, }); /\x2f Show the dropdown when the input box is focused. this._inputElement.addEventListener("focus", () => this._dropdownOpener.visible = true, true); /\x2f Search submission: helpers.inputHandler(this._inputElement, this._submitSearch); this.root.querySelector(".search-submit-button").addEventListener("click", this._submitSearch); } /\x2f Hide the dropdowns if our tree becomes hidden. visibilityChanged() { super.visibilityChanged(); if(!this.visibleRecursively) this._dropdownOpener.visible = false; } /\x2f Run a text prompt. /\x2f /\x2f We need to keep ourself from closing when the prompt takes our focus temporarily, and restore /\x2f our focus when it's finished. async dialog(promise) { this._showingDialog = true; try { return await promise; } finally { this._inputElement.focus(); this._showingDialog = false; } } textPrompt(options) { return this.dialog(TextPrompt.prompt(options)); } confirmPrompt(options) { return this.dialog(ConfirmPrompt.prompt(options)); } _submitSearch = (e) => { /\x2f This can be sent to either the search page search box or the one in the /\x2f navigation dropdown. Figure out which one we're on. let tags = this._inputElement.value.trim(); if(tags.length == 0) return; /\x2f Add this tag to the recent search list. SavedSearchTags.add(tags); /\x2f If we're submitting by pressing enter on an input element, unfocus it and /\x2f close any widgets inside it (tag dropdowns). if(e.target instanceof HTMLInputElement) { e.target.blur(); this._dropdownOpener.visible = false; } /\x2f Run the search. let args = helpers.getArgsForTagSearch(tags, ppixiv.plocation); helpers.navigate(args); } } class TagSearchDropdownWidget extends widget { constructor({inputElement, savedPosition, textPrompt, ...options}) { super({...options, template: \`
\${ helpers.createIcon("mat:create_new_folder") }
Add section
\`}); this._autocompleteCache = new Map(); this._disableAutocompleteUntil = 0; this.savedPosition = savedPosition; this.textPrompt = textPrompt; /\x2f Find the . this._inputElement = inputElement.querySelector("input"); this._inputElement.addEventListener("keydown", this._inputKeydown); this._inputElement.addEventListener("input", this.inputOnInput); document.addEventListener("selectionchange", this._inputSelectionChange, { signal: this.shutdownSignal }); /\x2f Refresh the dropdown when the tag search history changes. window.addEventListener("recent-tag-searches-changed", this._populateDropdown, { signal: this.shutdownSignal }); /\x2f Update the selection if the page is navigated while we're open. window.addEventListener("pp:popstate", this._selectCurrentSearch, { signal: this.shutdownSignal }); this.root.addEventListener("click", this._dropdownClick); this._currentAutocompleteResults = []; /\x2f input-dropdown is resizable. Save the size when the user drags it. this._allResults = this.root; this._inputDropdown = this.root.querySelector(".input-dropdown-list"); this._inputDropdownContents = this._inputDropdown.querySelector(".contents"); let observer = new MutationObserver((mutations) => { /\x2f resize sets the width. Use this instead of offsetWidth, since offsetWidth sometimes reads /\x2f as 0 here. let width = parseInt(this.root.style.width); if(isNaN(width)) width = 600; ppixiv.settings.set("tag-dropdown-width", width); }); observer.observe(this.root, { attributes: true }); /\x2f Restore input-dropdown's width. this._inputDropdown.style.width = ppixiv.settings.get("tag-dropdown-width", "400px"); /\x2f tag-dropdown-width may have "px" baked into it. Use parseInt to remove it. let width = ppixiv.settings.get("tag-dropdown-width", "400"); width = parseInt(width); this.root.style.setProperty('--width', \`\${width}px\`); this.dragger = new DragHandler({ parent: this, name: "search-dragger", element: this.root, confirmDrag: ({event}) => event.target.closest(".drag-handle") != null, ondragstart: (args) => this._ondragstart(args), ondrag: (args) => this._ondrag(args), ondragend: (args) => this._ondragend(args), }); this.editing = false; this._load(); } get editing() { return this._editing; } set editing(value) { if(this._editing == value) return; this._editing = value; helpers.html.setClass(this.root, "editing", this._editing); helpers.html.setClass(this.root.querySelector(".input-dropdown-list"), "editing", this._editing); } _findTagEntry(tag) { for(let entry of this._inputDropdown.querySelectorAll(".entry[data-tag]")) { if(entry.dataset.tag == tag) return entry; } return null; } _ondragstart({event}) { /\x2f Remember the tag we're dragging. let dragHandle = event.target.closest(".drag-handle"); let entry = dragHandle.closest(".entry"); this.draggingTag = entry.dataset.tag; return true; } _ondrag({event}) { /\x2f Scan backwards or forwards to find the next valid place where entry can be placed /\x2f after. /\x2f Find the next and previous entry that we can drag to. function findSibling(entry, next) { let sibling = entry; while(sibling) { if(next) sibling = sibling.nextElementSibling; else sibling = sibling.previousElementSibling; if(sibling == null) return null; /\x2f If this is an uncollapsed tag or group, return it. if(!sibling.classList.contains("collapsed")) return sibling; } return null; } let entry = this._findTagEntry(this.draggingTag); /\x2f Check downwards first, then upwards. let entryRect = entry.getBoundingClientRect(); for(let down = 0; down <= 1; down++) { let entryToCheck = findSibling(entry, down == 1); if(entryToCheck == null) continue; if(!entryToCheck.classList.contains("saved") && !entryToCheck.classList.contains("tag-section")) continue; /\x2f When moving up, find the next entry where the entry above it is uncollapsed. /\x2f For tags this is always true (visible tags are always inside a visible group), /\x2f but if we're dragging above a group header, this makes sure we drag into an /\x2f uncollapsed group. /\x2f /\x2f To see if we should move up, compare the Y position to the center of the combination /\x2f of the element and the element above it. threshold is how far over the boundary /\x2f we need to go before moving. let neighborRect = entryToCheck.getBoundingClientRect(); let threshold = 5; if(down) { let y = (neighborRect.bottom + entryRect.top) / 2; if(event.clientY - threshold < y) continue; } else { let y = (entryRect.bottom + neighborRect.top) / 2; if(event.clientY + threshold > y) continue; } /\x2f We want to drag in this direction. If we're dragging downwards, we'll place the item /\x2f after entryToCheck. If we're dragging upwards, find the next uncollapsed entry before /\x2f it to place it after. let entryToPlaceAfter = entryToCheck; if(!down) entryToPlaceAfter = findSibling(entryToCheck, false); if(entryToPlaceAfter == null) continue; /\x2f Find its index in the list. let moveAfterIdx = -1; if(entryToPlaceAfter.groupName) moveAfterIdx = SavedSearchTags.findIndex({group: entryToPlaceAfter.groupName}); else if(entryToPlaceAfter.dataset.tag) moveAfterIdx = SavedSearchTags.findIndex({tag: entryToPlaceAfter.dataset.tag}); if(moveAfterIdx != -1) { /\x2f Move the tag after moveAfterIdx. SavedSearchTags.move(this.draggingTag, moveAfterIdx+1); return; } } }; _ondragend({event}) { this.draggingTag = null; } /\x2f Return the tag-section for the given group. /\x2f /\x2f We could do this with querySelector, but we'd need to escape the string. _getSectionHeaderForGroup(group) { for(let tagSection of this.root.querySelectorAll(".tag-section")) { if(tagSection.groupName == group) return tagSection; } return null; } getEntryForTag(tag, { includeAutocomplete=false }={}) { tag = tag.trim(); for(let entry of this.root.querySelectorAll(".entry")) { if(!includeAutocomplete && entry.classList.contains("autocomplete")) continue; if(entry.dataset.tag.trim() == tag) return entry; } return null; } _dropdownClick = async(e) => { let entry = e.target.closest(".entry"); let tagSection = e.target.closest(".tag-section"); let createSectionButton = e.target.closest(".create-section-button"); if(createSectionButton) { e.stopPropagation(); e.preventDefault(); let label = await this.textPrompt({ title: "Group name:" }); if(label == null) return; /\x2f cancelled /\x2f Group names identify the group, so don't allow adding a group that already exists. /\x2f SavedSearchTags.add won't allow this, but check so we can tell the user. let tagGroups = new Set(SavedSearchTags.getAllGroups().keys()); if(tagGroups.has(label)) { ppixiv.message.show(\`Group "\${label}" already exists\`); return; } /\x2f Add the group. SavedSearchTags.add(null, { group: label }); /\x2f The edit will update automatically, but that happens async and may not have /\x2f completed yet. Force an update now so we can scroll the new group into view. await this._populateDropdown(); let newSection = this._getSectionHeaderForGroup(label); this._scrollEntryIntoView(newSection); return; } let tagButton = e.target.closest("a[data-tag]"); if(tagButton) { if(this.editing) { /\x2f Don't navigate on click while we're editing tags. Note that the anchor is around /\x2f the buttons, so this may be a click on an editor button too. /\x2f e.stopPropagation(); e.preventDefault(); } else { /\x2f When a tag link is clicked, hide and also unfocus the input box so clicking it will /\x2f reopen us. this._inputElement.blur(); this.hide(); /\x2f If this is a navigation the input box will be filled automatically, but clicking an /\x2f entry matching the current search won't navigate. Fill in the input box with the search /\x2f even if the click doesn't trigger navigation. this._inputElement.value = entry.dataset.tag; return; } } if(this.editing) { let moveGroupUp = e.target.closest(".move-group-up"); let moveGroupDown = e.target.closest(".move-group-down"); if(moveGroupUp || moveGroupDown) { e.stopPropagation(); e.preventDefault(); SavedSearchTags.moveGroup(tagSection.groupName, { down: moveGroupDown != null }); return; } let saveSearch = e.target.closest(".save-search"); if(saveSearch) { e.stopPropagation(); e.preventDefault(); /\x2f Figure out which group to put it in. If there are no groups, this is the first /\x2f saved search, so create "Saved tags" by default. If there's just one group, use /\x2f it. Otherwise, ask the user. /\x2f /\x2f maybe only expand one group at a time let tagGroups = new Set(SavedSearchTags.getAllGroups().keys()); tagGroups.delete(null); /\x2f ignore the recents group let addToGroup = "Saved tags"; if(tagGroups.size == 1) addToGroup = Array.from(tagGroups)[0]; else if(tagGroups.size > 1) { /\x2f For now, add to the bottommost uncollapsed group. This is a group which is /\x2f closest to recents, where the user should be able to see where the tag he /\x2f saved went. let allGroups = new Set(SavedSearchTags.getAllGroups().keys()); allGroups.delete(null); let collapsedGroups = SavedSearchTags.getCollapsedTagGroups(); addToGroup = null; for(let group of allGroups) { if(collapsedGroups.has(group)) continue; addToGroup = group; } if(addToGroup == null) { /\x2f If no groups are uncollapsed, use the last group. It'll be uncollapsed /\x2f below. for(let group of allGroups) addToGroup = group; } } console.log(\`Adding search "\${entry.dataset.tag}" to group "\${addToGroup}"\`); /\x2f If the group we're adding to is collapsed, uncollapse it. if(SavedSearchTags.getCollapsedTagGroups().has(addToGroup)) { console.log(\`Uncollapsing group \${addToGroup} because we're adding to it\`); SavedSearchTags.setTagGroupCollapsed(addToGroup, false); } /\x2f Add or change the tag to a saved tag. SavedSearchTags.add(entry.dataset.tag, {group: addToGroup, addToEnd: true}); /\x2f We tried to keep the new tag in view, but scroll it into view if it isn't, such as /\x2f if we had to expand the group and the scroll position is in the wrong place now. await this._populateDropdown(); let newEntry = this.getEntryForTag(entry.dataset.tag); this._scrollEntryIntoView(newEntry); } let editTags = e.target.closest(".edit-tags-button"); if(editTags != null) { e.stopPropagation(); e.preventDefault(); /\x2f Add a space to the end for convenience with the common case of just wanting to add something /\x2f to the end. let newTags = await this.textPrompt({ title: "Edit search:", value: entry.dataset.tag + " " }); if(newTags == null || newTags == entry.dataset.tag) return; /\x2f cancelled newTags = newTags.trim(); SavedSearchTags.modifyTag(entry.dataset.tag, newTags); return; } let removeEntry = e.target.closest(".delete-entry"); if(removeEntry != null) { /\x2f Clicked X to remove a tag or group. e.stopPropagation(); e.preventDefault(); if(entry != null) { SavedSearchTags.remove(entry.dataset.tag); return; } /\x2f This isn't a tag, so it must be a group. If the group has no items in it, just remove /\x2f it. If it does have items, confirm first. let tagsInGroup = SavedSearchTags.getAllGroups().get(tagSection.groupName); if(tagsInGroup.length > 0) { let header, text = null; if(tagSection.groupName == null) header = \`Clear \${tagsInGroup.length} recent \${tagsInGroup.length == 1? "search":"searches"}?\`; else { header = "Delete tag group"; text = \`This group contains \${tagsInGroup.length} \${tagsInGroup.length == 1? "tag":"tags"}. Delete this group and all tags inside it? This can't be undone.\`; } let result = await this.parent.confirmPrompt({ header, text }); if(!result) return; } console.log("Deleting group:", tagSection.groupName); console.log("Containing tags:", tagsInGroup); SavedSearchTags.deleteGroup(tagSection.groupName); return; } let renameGroup = e.target.closest(".rename-group-button"); if(renameGroup != null) { e.stopPropagation(); e.preventDefault(); /\x2f The recents group can't be renamed. if(tagSection.groupName == null) return; let newGroupName = await this.textPrompt({ title: "Rename group:", value: tagSection.groupName }); if(newGroupName == null || newGroupName == tagSection.groupName) return; /\x2f cancelled SavedSearchTags.renameGroup(tagSection.groupName, newGroupName); return; } } /\x2f Toggling tag sections: if(tagSection != null && !tagSection.classList.contains("autocomplete")) { e.stopPropagation(); e.preventDefault(); SavedSearchTags.setTagGroupCollapsed(tagSection.groupName, "toggle"); return; } } _inputKeydown = (e) => { /\x2f Only handle inputs when we're open. if(this.root.hidden) return; switch(e.code) { case "ArrowUp": case "ArrowDown": e.preventDefault(); e.stopImmediatePropagation(); /\x2f Disabled for now since keyboard navigation is currently broken. this.move(e.code == "ArrowDown"); break; } } _inputSelectionChange = (e) => { this._runAutocomplete(); } inputOnInput = (e) => { if(this.root.hidden) return; /\x2f Clear the selection on input. this.setSelection(null); /\x2f Update autocomplete when the text changes. this._runAutocomplete(); } async _load() { /\x2f We need to go async to load translations, and if we become visible before then we'll flash /\x2f an unfilled dialog (this is annoying since it's a local database and the load is always /\x2f nearly instant). But, if we're hidden then we have no layout, so things like restoring /\x2f the scroll position and setting the max height don't work. Work around this by making ourselves /\x2f visible immediately, but staying transparent, so we have layout but aren't visible until we're /\x2f ready. this.root.classList.add("loading"); this.root.hidden = false; /\x2f Fill in the dropdown before displaying it. This returns false if we were hidden before /\x2f we finished loading. if(!await this._populateDropdown()) return; this._selectCurrentSearch(); this._runAutocomplete(); } hide() { if(!this.visible) return; this.visible = false; /\x2f If _populateDropdown is still running, cancel it. this._cancelPopulateDropdown(); this._currentAutocompleteResults = []; this._mostRecentAutocomplete = null; this.editing = false; this.dragger.cancelDrag(); this.root.hidden = true; } async _runAutocomplete() { /\x2f Don't refresh if we're not visible. if(!this.visible) return; /\x2f If true, this is a value change caused by keyboard navigation. Don't run autocomplete, /\x2f since we don't want to change the dropdown due to navigating in it. if(this.navigating) return; if(this._disableAutocompleteUntil > Date.now()) return; let tags = this._inputElement.value.trim(); /\x2f Get the word under the cursor (we ignore UTF-16 surrogates here for now). This is /\x2f the word we'll replace if the user selects a result. If there's no selection this /\x2f is also the word we'll search for. let text = this._inputElement.value; let wordStart = this._inputElement.selectionStart; while(wordStart > 0 && text[wordStart-1] != " ") wordStart--; let wordEnd = this._inputElement.selectionEnd; while(wordEnd < text.length && text[wordEnd] != " ") wordEnd++; /\x2f Get the text to search for. if the selection is collapsed, use the whole word. /\x2f If we have a selection, search for just the selected text. let keyword; if(this._inputElement.selectionStart != this._inputElement.selectionEnd) keyword = text.substr(this._inputElement.selectionStart, this._inputElement.selectionEnd-this._inputElement.selectionStart); else keyword = text.substr(wordStart, wordEnd-wordStart); keyword = keyword.trim(); /\x2f If the word contains a space because the user selected multiple words, delete /\x2f everything after the first space. keyword = keyword.replace(/ .*/, ""); /\x2f Remove grouping parentheses. keyword = keyword.replace(/^\\(+/g, ''); keyword = keyword.replace(/\\)+\$/g, ''); /\x2f Don't autocomplete the search keyword "or". if(keyword == "or") return; /\x2f Stop if we're already up to date. if(this._mostRecentAutocomplete == keyword) return; if(this._abortAutocomplete != null) { /\x2f If an autocomplete request is already running, let it finish before we /\x2f start another. This matches the behavior of Pixiv's input forms. return; } this._mostRecentAutocomplete = keyword; /\x2f See if we have this search cached, so we don't spam requests if the user /\x2f moves the cursor around a lot. let cachedResult = this._autocompleteCache.get(keyword); if(cachedResult != null) { this._autocompleteRequestFinished(tags, keyword, { candidates: cachedResult, text, wordStart, wordEnd }); return; } /\x2f Don't send requests with an empty string. Just finish the search synchronously, /\x2f so we clear the autocomplete immediately. if(keyword == "") { if(this._abortAutocomplete != null) this._abortAutocomplete.abort(); this._autocompleteRequestFinished(tags, keyword, { candidates: [] }); return; } /\x2f Run the search. let result = null; try { this._abortAutocomplete = new AbortController(); result = await helpers.pixivRequest.get("/rpc/cps.php", { keyword, }, { signal: this._abortAutocomplete.signal, }); } catch(e) { console.info("Tag autocomplete error:", e); return; } finally { this._abortAutocomplete = null; } /\x2f If result is null, we were probably aborted. if(result == null) return; this._autocompleteRequestFinished(tags, keyword, { candidates: result.candidates, text, wordStart, wordEnd }); } /\x2f A tag autocomplete request finished. _autocompleteRequestFinished(tags, word, { candidates, text, wordStart, wordEnd }={}) { this._abortAutocomplete = null; /\x2f Cache the result. this._autocompleteCache.set(word, candidates); /\x2f Cache any translated tags the autocomplete gave us. let translations = { }; for(let tag of candidates) { /\x2f Only cache translations, not romanizations. if(tag.type != "tag_translation") continue; translations[tag.tag_name] = { en: tag.tag_translation }; } ppixiv.tagTranslations.addTranslationsDict(translations); /\x2f Store the results. this._currentAutocompleteResults = []; for(let candidate of candidates || []) { /\x2f Skip the word we searched for, since it's the text we already have. if(candidate.tag_name == word) continue; /\x2f If the input has multiple tags, we're searching the tag the cursor was on. Replace just /\x2f that word. let search = text.slice(0, wordStart) + candidate.tag_name + text.slice(wordEnd); this._currentAutocompleteResults.push({ tag: candidate.tag_name, search }); } /\x2f Refresh the dropdown with the new results. Scroll to autocomplete if we're filling it in /\x2f because of the user typing a tag, but not for things like clicking on the input box, so /\x2f we don't steal the scroll position. this._populateDropdown(); /\x2f If the input element's value has changed since we started this search, we /\x2f stalled any other autocompletion. Start it now. if(tags != this._inputElement.value) this._runAutocomplete(); } /\x2f tagSearch is a search, like "tag -tag2". /\x2f /\x2f tags is the tag list to display. The entry will link to targetTags, or tags /\x2f if targetTags is null. createEntry(tags, { classes, targetTags=null }={}) { let entry = this.createTemplate({name: "tag-dropdown-entry", html: \`
\${ helpers.createIcon("mat:drag_handle") }
\${ helpers.createIcon("mat:edit") } X
\`}); targetTags ??= tags; entry.dataset.tag = targetTags; for(let name of classes) entry.classList.add(name); let translatedTag = this.translatedTags[tags]; if(translatedTag) entry.dataset.translatedTag = translatedTag; let tagContainer = entry.querySelector(".search"); for(let tag of helpers.pixiv.splitSearchTags(tags)) { if(tag == "") continue; /\x2f 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"); /\x2f Split off - prefixes to look up the translation, then add it back. let prefixAndTag = helpers.pixiv.splitTagPrefixes(tag); let translatedTag = this.translatedTags[prefixAndTag[1]]; if(translatedTag) translatedTag = prefixAndTag[0] + translatedTag; span.textContent = translatedTag || tag; if(translatedTag) span.dataset.translatedTag = translatedTag; tagContainer.appendChild(span); } let url = helpers.getArgsForTagSearch(targetTags, ppixiv.plocation); entry.href = url; return entry; } createSeparator(label, { icon, isUserSection, groupName=null, collapsed=false, classes=[] }) { let section = this.createTemplate({html: \`
\${ helpers.createIcon("mat:arrow_upward") }
\${ helpers.createIcon("mat:arrow_downward") }
\${ helpers.createIcon(icon, { classes: ['section-icon']}) } \${ helpers.createIcon("mat:edit") } X
\`}); section.querySelector(".label").textContent = label; helpers.html.setClass(section, "user-section", isUserSection); helpers.html.setClass(section, "collapsed", collapsed); if(groupName != null) section.dataset.group = groupName; else section.classList.add("recents"); section.groupName = groupName; if(groupName == null) section.querySelector(".rename-group-button").hidden = true; for(let name of classes) section.classList.add(name); return section; } /\x2f Select the next or previous entry in the dropdown. move(down) { /\x2f Temporarily set this.navigating to true. This lets _runAutocomplete know that /\x2f it shouldn't run an autocomplete request for this value change. this.navigating = true; try { let allEntries = this._allResults.querySelectorAll(".entry"); /\x2f Stop if there's nothing in the list. let totalEntries = allEntries.length; if(totalEntries == 0) return; /\x2f Find the index of the previous selection, if any. let selectedIdx = null; for(let idx = 0; idx < allEntries.length; ++idx) { if(allEntries[idx].classList.contains("selected")) { selectedIdx = idx; break; } } if(selectedIdx == null) selectedIdx = down? 0:(totalEntries-1); else selectedIdx += down? +1:-1; selectedIdx = (selectedIdx + totalEntries) % totalEntries; /\x2f If there's an autocomplete request in the air, cancel it. if(this._abortAutocomplete != null) this._abortAutocomplete.abort(); /\x2f Set the new selection. let newEntry = allEntries[selectedIdx]; this.setSelection(newEntry.dataset.tag); /\x2f selectionchange is fired async. This doesn't make sense, since it makes it /\x2f impossible to tell what triggered it: this.navigating will be false by the time /\x2f we see it. Work around this with a timer to disable autocomplete briefly. this._disableAutocompleteUntil = Date.now() + 50; this._inputElement.value = newEntry.dataset.tag; } finally { this.navigating = false; } } getSelection() { let entry = this._allResults.querySelector(".entry.selected"); return entry?.dataset?.tag; } setSelection(tags) { /\x2f Temporarily set this.navigating to true. This lets _runAutocomplete know that /\x2f it shouldn't run an autocomplete request for this value change. this.navigating = true; try { /\x2f Clear the old selection. let oldSelection = this._allResults.querySelector(".entry.selected"); if(oldSelection) oldSelection.classList.remove("selected"); /\x2f Find the entry for the given search. if(tags != null) { let entry = this.getEntryForTag(tags, { includeAutocomplete: true }); if(entry) { entry.classList.add("selected"); this._scrollEntryIntoView(entry); } } } finally { this.navigating = false; } } /\x2f If the current search is in the list, select it. _selectCurrentSearch = () => { let currentSearchTags = this._inputElement.value.trim(); if(!currentSearchTags) return; this.setSelection(currentSearchTags); /\x2f If that selected something, scroll it into view. let selectedEntry = this.root.querySelector(".entry.selected"); if(selectedEntry) this._scrollEntryIntoView(selectedEntry); } _populateDropdown = async(options) => { /\x2f If this is called again before the first call completes, the original call will be /\x2f aborted. Keep waiting until one completes without being aborted (or we're hidden), so /\x2f we don't return until our contents are actually filled in. let promise = this._populateDropdownPromise = this._populateDropdownInner(options); this._populateDropdownPromise.finally(() => { if(promise === this._populateDropdownPromise) this._populateDropdownPromise = null; }); while(this.visible && this._populateDropdownPromise != null) { if(await this._populateDropdownPromise) return true; } return false; } /\x2f Populate the tag dropdown. /\x2f /\x2f This is async, since IndexedDB is async. (It shouldn't be. It's an overcorrection. /\x2f Network APIs should be async, but local I/O should not be forced async.) If another /\x2f call to _populateDropdown() is made before this completes or _cancelPopulateDropdown /\x2f cancels it, return false. If it completes, return true. _populateDropdownInner = async() => { /\x2f If another _populateDropdown is already running, cancel it and restart. this._cancelPopulateDropdown(); /\x2f Set populate_dropdown_abort to an AbortController for this call. let abortController = this._populateDropdownAbort = new AbortController(); let abortSignal = abortController.signal; let autocompletedTags = this._currentAutocompleteResults || []; let tagsByGroup = SavedSearchTags.getAllGroups(); let allSavedTags = []; for(let savedTag of tagsByGroup.values()) allSavedTags = [...allSavedTags, ...savedTag]; for(let tag of autocompletedTags) allSavedTags.push(tag.tag); /\x2f Separate tags in each search, so we can look up translations. let allTags = {}; for(let tagSearch of allSavedTags) { for(let tag of helpers.pixiv.splitSearchTags(tagSearch)) { tag = helpers.pixiv.splitTagPrefixes(tag)[1]; allTags[tag] = true; } } allTags = Object.keys(allTags); /\x2f Get tag translations. /\x2f /\x2f Don't do this if we're updating the list during a drag. The translations will never change /\x2f since we're just reordering the list, and we need to avoid going async to make sure we update /\x2f the list immediately since the drag will get confused if it isn't. let translatedTags; if(this.draggingTag == null) { translatedTags = await ppixiv.tagTranslations.getTranslations(allTags, "en"); /\x2f Check if we were aborted while we were loading tags. if(abortSignal.aborted) return false; this.translatedTags = translatedTags; } /\x2f Save the selection so we can restore it. let savedSelection = this.getSelection(); /\x2f If we were given a saved scroll position, use it the first time we open. Otherwise, /\x2f save the current position. This preserves the scroll position when we're destroyed /\x2f and recreated, and when we refresh due tothings like autocomplete changing. let savedPosition = this.savedPosition ?? this._saveSearchPosition(); this.savedPosition = null; savedPosition ??= {}; helpers.html.removeElements(this._inputDropdownContents); /\x2f Add autocompletes at the top. if(autocompletedTags.length) this._inputDropdownContents.appendChild(this.createSeparator(\`Suggestions for \${this._mostRecentAutocomplete}\`, { icon: "mat:assistant", classes: ["autocomplete"] })); for(let tag of autocompletedTags) { /\x2f Autocomplete entries link to the fully completed search, but only display the /\x2f tag that was searched for. let entry = this.createEntry(tag.tag, { classes: ["autocomplete"], targetTags: tag.search }); this._inputDropdownContents.appendChild(entry); } /\x2f Show saved tags above recent tags. for(let [groupName, tagsInGroup] of tagsByGroup.entries()) { /\x2f Skip recents. if(groupName == null) continue; let collapsed = SavedSearchTags.getCollapsedTagGroups().has(groupName); this._inputDropdownContents.appendChild(this.createSeparator(groupName, { icon: collapsed? "mat:folder":"mat:folder_open", isUserSection: true, groupName: groupName, collapsed, })); /\x2f Add contents if this section isn't collapsed. if(!collapsed) { for(let tag of tagsInGroup) this._inputDropdownContents.appendChild(this.createEntry(tag, { classes: ["history", "saved"] })); } } /\x2f Show recent searches. This group always exists, but hide it if it's empty. let recentsCollapsed = SavedSearchTags.getCollapsedTagGroups().has(null); let recentTags = tagsByGroup.get(null); if(recentTags.length) this._inputDropdownContents.appendChild(this.createSeparator("Recent tags", { icon: "mat:history", collapsed: recentsCollapsed, })); if(!recentsCollapsed) { for(let tag of recentTags) this._inputDropdownContents.appendChild(this.createEntry(tag, { classes: ["history", "recent"] })); } /\x2f Restore the previous selection. if(savedSelection) this.setSelection(savedSelection); this._restoreSearchPosition(savedPosition); /\x2f We're populated now, so if we were hidden for initial loading, we can actually show /\x2f our contents if we have any. let empty = Array.from(this._allResults.querySelectorAll(".entry, .tag-section")).length == 0; helpers.html.setClass(this.root, "loading", empty); return true; } _cancelPopulateDropdown() { if(this._populateDropdownAbort == null) return; this._populateDropdownAbort.abort(); } /\x2f Save the current search position, to be restored with _restoreSearchPosition. /\x2f This can be used as the savedPosition argument to the constructor. _saveSearchPosition() { /\x2f If we're dragging, never save the search position relative to the tag that's /\x2f being dragged, or the tag on either side. This keeps the scroll position stable /\x2f when the drag moves and swaps a tag with its neighbor. let ignoredNodes = new Set(); if(this.draggingTag) { let entry = this._findTagEntry(this.draggingTag); ignoredNodes.add(entry); let nextEntry = entry.nextElementSibling; if(nextEntry) ignoredNodes.add(nextEntry); let previousEntry = entry.previousElementSibling; if(previousEntry) ignoredNodes.add(previousEntry); } for(let node of this._inputDropdown.querySelectorAll(".entry[data-tag]")) { if(node.offsetTop < this.root.scrollTop) continue; if(ignoredNodes.has(node)) continue; let savedPosition = helpers.html.saveScrollPosition(this.root, node); let tag = node.dataset.tag; return { savedPosition, tag }; } return { }; } _restoreSearchPosition({ savedPosition, tag }) { if(savedPosition == null) return; let restoreEntry = this.getEntryForTag(tag); if(restoreEntry) helpers.html.restoreScrollPosition(this.root, restoreEntry, savedPosition); } /\x2f Scroll a row into view. entry can be an entry or a section header. _scrollEntryIntoView(entry) { entry.scrollIntoView({ block: "nearest" }); if(!entry.classList.contains("entry")) return; /\x2f Work around a bug in most browsers: scrollIntoView will scroll an element underneath /\x2f sticky headers, where it isn't in view at all. This is a pain, because there's no direct /\x2f way to find which element is actually the top sticky header. We have to scan through the /\x2f list and find it. All nodes that are stickied will have the same offsetTop, so we need /\x2f to find the last sticky node with the same offsetTop as the first one. let stickyTop = null; for(let node of this._inputDropdownContents.children) { if(!node.classList.contains("tag-section")) continue; if(stickyTop != null && node.offsetTop != stickyTop.offsetTop) break; stickyTop = node; } /\x2f If entry is underneath the header, scroll down to make it visible. The extra offsetTop /\x2f adjustment is to adjust for the autocomplete box above the scroller. let stickyPadding = stickyTop.offsetHeight; let offsetFromTop = entry.offsetTop - this._inputDropdown.offsetTop - this.root.scrollTop; if(offsetFromTop < stickyPadding) this.root.scrollTop -= stickyPadding - offsetFromTop; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/tag-search-dropdown.js `), "/vview/widgets/user-widgets.js": loadBlob("application/javascript", `import Widget from '/vview/widgets/widget.js'; import Actions from '/vview/misc/actions.js'; import AsyncLookup from '/vview/actors/async-lookup.js'; import { DropdownBoxOpener } from '/vview/widgets/dropdown.js'; import { ConfirmPrompt, TextPrompt } from '/vview/widgets/prompts.js'; import { helpers } from '/vview/misc/helpers.js'; /\x2f AsyncLookup to look up user info from a user ID. export class GetUserInfo extends AsyncLookup { constructor({ /\x2f The data this widget needs. This can be: /\x2f - userId - Just the ID itself /\x2f - partial - Partial user info. /\x2f - full - Full user info. This is less likely to be available from cache. /\x2f /\x2f This can be mediaId (nothing but the ID), full or partial. /\x2f /\x2f This can change dynamically. Some widgets need media info only when viewing a manga /\x2f page. neededData="full", ...options }) { super({...options}); this._neededData = neededData; if(!(this._neededData instanceof Function)) this._neededData = () => neededData; /\x2f Refresh when the user data changes. We don't watch for changes to media IDs since /\x2f we don't expect the user for an image to change. ppixiv.userCache.addEventListener("usermodified", (e) => { if(e.userId == this._id) this.refresh(); }, this._signal); } async _refreshInner() { if(this.hasShutdown) return; let userId = this._id; let info = { userId: this._id }; /\x2f If we have a user ID and we want user info (not just the user ID itself), load it. let neededData = this._neededData(); if(this._id != null && neededData != "userId") { let full = neededData == "full"; /\x2f See if we have the data the widget wants already. info.userInfo = ppixiv.userCache.getUserInfoSync(this._id, { full }); /\x2f If we need to load data, clear the widget while we load, so we don't show the old /\x2f data while we wait for data. Skip this if we don't need to load, so we don't clear /\x2f and reset the widget. This can give the widget an illust ID without data, which is /\x2f OK. if(info.userInfo == null) { await this._onrefresh(info); /\x2f Don't make API requests for data if we're not visible to the user. if(!this._loadWhileNotVisible && !this.actuallyVisibleRecursively) return; info.userInfo = await ppixiv.userCache.getUserInfo(this._id, { full }); } } /\x2f Stop if the media ID changed while we were async. if(this._id != userId) return; await this._onrefresh(info); } } /\x2f Async lookups to get user IDs from media IDs. /\x2f /\x2f If a media ID is a user ("user:1234"), return it. If it's an illust, look up the illust /\x2f and return its author's user ID. export class GetUserIdFromMediaId extends AsyncLookup { constructor({ ...options }) { super({...options}); this._id = null; } async _refreshInner() { let mediaId = this._id; this._info = { }; if(this._id != null) { /\x2f If the media ID is a user ID, use it. let { type, id } = helpers.mediaId.parse(mediaId); if(type == "user") this._info.userId = id; else { /\x2f See if we can get media ID synchronously. let mediaInfo = ppixiv.mediaCache.getMediaInfoSync(this._id, { full: false }); this._info.userId = mediaInfo?.userId; if(this._info.userId == null) { await this._onrefresh(this._info); /\x2f Don't make API requests for data if we're not visible to the user. if(!this._loadWhileNotVisible && !this.actuallyVisibleRecursively) return; mediaInfo = await ppixiv.mediaCache.getMediaInfo(this._id, { full: false }); this._info.userId = mediaInfo?.userId; } } } /\x2f Stop if the media ID changed while we were async. if(this._id != mediaId) return; await this._onrefresh(this._info); } } export class AvatarWidget extends Widget { constructor({ /\x2f This is called when the follow dropdown visibility changes. dropdownvisibilitychanged=() => { }, clickAction="dropdown", ...options }={}) { super({...options, template: \` \`}); this.options = options; if(clickAction != "dropdown" && clickAction != "author") throw new Error(\`Invalid avatar widget mode: \${clickAction}\`); this.getUserInfo = new GetUserInfo({ parent: this, onrefresh: (args) => this.onrefresh(args), }); let avatarElement = this.root.querySelector(".avatar"); let avatarLink = this.root; this.followDropdownOpener = new DropdownBoxOpener({ button: avatarLink, onvisibilitychanged: dropdownvisibilitychanged, asDialog: ppixiv.mobile, createDropdown: ({...options}) => { return new FollowWidget({ ...options, userId: this.userId, close: () => this.followDropdownOpener.visible = false, }); }, }); avatarLink.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); if(clickAction == "dropdown") this.followDropdownOpener.visible = !this.followDropdownOpener.visible; else if(clickAction == "author") { let args = new helpers.args(\`/users/\${this.userId}#ppixiv\`); helpers.navigate(args, { scrollToTop: true }); } }); /\x2f Clicking the avatar used to go to the user page, but now it opens the follow dropdown. /\x2f Allow doubleclicking it instead, to keep it quick to go to the user. avatarLink.addEventListener("dblclick", (e) => { e.preventDefault(); e.stopPropagation(); let args = new helpers.args(\`/users/\${this.userId}/artworks#ppixiv\`); helpers.navigate(args, { scrollToTop: true }); }); /\x2f A canvas filter for the avatar. This has no actual filters. This is just to kill off any /\x2f annoying GIF animations in people's avatars. this.img = document.createElement("img"); this._baseFilter = new ImageCanvasFilter(this.img, avatarElement); this.root.dataset.mode = this.options.mode; new CreepyEyeWidget({ container: this.root.querySelector(".follow-icon"), pointerTarget: this.root, }); } visibilityChanged() { super.visibilityChanged(); this.refresh(); } /\x2f Return the dropdown if it's open. get userDropdownWidget() { return this.followDropdownOpener.dropdown; } get userId() { return this.getUserInfo.id; } async setUserId(userId) { /\x2f Close the dropdown if the user is changing. if(this.getUserInfo.id != userId && this.followDropdownOpener) this.followDropdownOpener.visible = false; this.getUserInfo.id = userId; this.refresh(); } onrefresh({userId, userInfo}) { if(userId == null || userId == -1) { /\x2f Set the avatar image to a blank image, so it doesn't flash the previous image /\x2f the next time we display it. It should never do this, since we set a new image /\x2f before displaying it, but Chrome doesn't do this correctly at least with canvas. this.img.src = helpers.other.blankImage; return; } /\x2f If we've seen this user's profile image URL from thumbnail data, we can use it to /\x2f start loading the avatar without waiting for user info to finish loading. let cachedProfileUrl = ppixiv.mediaCache.userProfileUrls[userId]; this.img.src = cachedProfileUrl ?? userInfo?.imageBig ?? helpers.other.blankImage; /\x2f Set up stuff that we don't need user info for. this.root.href = \`/users/\${userId}/artworks#ppixiv\`; /\x2f Hide the popup in dropdown mode, since it covers the dropdown. if(this.options.mode == "dropdown") this.root.querySelector(".avatar").classList.remove("popup"); /\x2f Clear stuff we need user info for, so we don't show old data while loading. helpers.html.setClass(this.root, "followed", false); this.root.querySelector(".avatar").dataset.popup = ""; this.root.querySelector(".follow-icon").hidden = !(userInfo?.isFollowed ?? false); this.root.querySelector(".avatar").dataset.popup = userInfo?.name ?? ""; } }; /\x2f Filter an image to a canvas. /\x2f /\x2f When an image loads, draw it to a canvas of the same size, optionally applying filter /\x2f effects. /\x2f /\x2f If baseFilter is supplied, it's a filter to apply to the top copy of the image. /\x2f If overlay(ctx, img) is supplied, it's a function to draw to the canvas. This can /\x2f be used to mask the top copy. class ImageCanvasFilter { constructor(img, canvas, baseFilter, overlay) { this.img = img; this.canvas = canvas; this._baseFilter = baseFilter || ""; this.overlay = overlay; this.ctx = this.canvas.getContext("2d"); this.img.addEventListener("load", this._updateCanvas); /\x2f For some reason, browsers can't be bothered to implement onloadstart, a seemingly /\x2f fundamental progress event. So, we have to use a mutation observer to tell when /\x2f the image is changed, to make sure we clear it as soon as the main image changes. this.observer = new MutationObserver((mutations) => { for(let mutation of mutations) { if(mutation.type == "attributes") { if(mutation.attributeName == "src") { this._updateCanvas(); } } } }); this.observer.observe(this.img, { attributes: true }); this._updateCanvas(); } clear() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this._currentUrl = helpers.other.blankImage; } _updateCanvas = () => { /\x2f The URL for the image we're rendering. If the image isn't complete, use the blank image /\x2f URL instead, since we're just going to clear. let currentUrl = this.img.src; if(!this.img.complete) currentUrl = helpers.other.blankImage; if(currentUrl == this._currentUrl) return; helpers.html.setClass(this.canvas, "loaded", false); this.canvas.width = this.img.naturalWidth; this.canvas.height = this.img.naturalHeight; this.clear(); this._currentUrl = currentUrl; /\x2f If we're rendering the blank image (or an incomplete image), stop. if(currentUrl == helpers.other.blankImage) return; /\x2f Draw the image onto the canvas. this.ctx.save(); this.ctx.filter = this._baseFilter; this.ctx.drawImage(this.img, 0, 0); this.ctx.restore(); /\x2f Composite on top of the base image. this.ctx.save(); if(this.overlay) this.overlay(this.ctx, this.img); this.ctx.restore(); /\x2f Use destination-over to draw the image underneath the overlay we just drew. this.ctx.globalCompositeOperation = "destination-over"; this.ctx.drawImage(this.img, 0, 0); helpers.html.setClass(this.canvas, "loaded", true); } } /\x2f A pointless creepy eye. Looks away from the mouse cursor when hovering over /\x2f the unfollow button. class CreepyEyeWidget extends Widget { constructor({ pointerTarget, ...options }={}) { super({...options, template: \` \`}); pointerTarget.addEventListener("mouseover", this.onevent, { capture: true, ...this._signal }); pointerTarget.addEventListener("mouseout", this.onevent, { capture: true, ...this._signal }); pointerTarget.addEventListener("pointermove", this.onevent, { capture: true, ...this._signal }); } onevent = (e) => { /\x2f We're set to pointer-events: none so we don't steal clicks from our container, so we have /\x2f to figure out if the cursor is over us manually. let { left, top, right, bottom } = this.root.getBoundingClientRect(); this.hover = left <= e.clientX && e.clientX <= right && top <= e.clientY && e.clientY <= bottom; if(e.type == "mouseover") this.hover = true; if(e.type == "mouseout") this.hover = false; let eyeMiddle = this.root.querySelector(".middle"); if(!this.hover) { eyeMiddle.style.transform = ""; return; } let mouse = [e.clientX, e.clientY]; let bounds = this.root.getBoundingClientRect(); let eye = [bounds.x + bounds.width/2, bounds.y + bounds.height/2]; let vectorLength = (vec) =>Math.sqrt(vec[0]*vec[0] + vec[1]*vec[1]); /\x2f Normalize to get a direction vector. let normalizeVector = (vec) => { let length = vectorLength(vec); if(length < 0.0001) return [0,0]; return [vec[0]/length, vec[1]/length]; }; let pos = [mouse[0] - eye[0], mouse[1] - eye[1]]; pos = normalizeVector(pos); if(Math.abs(pos[0]) < 0.5) { let negative = pos[0] < 0; pos[0] = 0.5; if(negative) pos[0] *= -1; } /\x2f pos[0] = 1 - ((1-pos[0]) * (1-pos[0])); pos[0] *= -3; pos[1] *= -6; eyeMiddle.style.transform = "translate(" + pos[0] + "px, " + pos[1] + "px)"; } } /\x2f Dropdown to follow and unfollow users class FollowWidget extends Widget { constructor({ userId=null, /\x2f This is called if we want to close our container. close=() => { }, ...options }) { super({ ...options, template: \` \`}); this.userId = userId; this.close = close; this.data = { }; this.viewPosts = this.querySelector(".view-posts"); this.root.querySelector(".follow-button-public").addEventListener("click", (e) => this._clickedFollow(false)); this.root.querySelector(".follow-button-private").addEventListener("click", (e) => this._clickedFollow(true)); this.root.querySelector(".toggle-follow-button-public").addEventListener("click", (e) => this._clickedFollow(false)); this.root.querySelector(".toggle-follow-button-private").addEventListener("click", (e) => this._clickedFollow(true)); this.root.querySelector(".unfollow-button").addEventListener("click", (e) => this._clickedUnfollow()); /\x2f Refresh if the user we're displaying changes. ppixiv.userCache.addEventListener("usermodified", this._userChanged, this._signal); ppixiv.muting.addEventListener("mutes-changed", () => this.refresh(), this._signal); this.followTagDropdownOpener = new DropdownBoxOpener({ button: this.querySelector(".follow-tags"), clickToOpen: true, createDropdown: ({...options}) => { return new FollowTagWidget({ ...options, userId: this.userId, }); }, }); this.loadUser(); } _userChanged = ({userId}) => { if(!this.visible || userId != this.userId) return; this.loadUser(); }; async loadUser() { if(!this.visible) return; /\x2f Refresh with no data. this.data = { }; this.refresh(); /\x2f If user info is already loaded, use it and refresh now, otherwise request it. let userInfo = ppixiv.userCache.getUserInfoSync(this.userId); if(userInfo) this.data.userInfo = userInfo; else userInfo = ppixiv.userCache.getUserInfo(this.userId); /\x2f Do the same for the user profile. If we're requesting both of these, they'll run /\x2f in parallel. let userProfile = ppixiv.userCache.getUserProfileSync(this.userId); if(userProfile) this.data.userProfile = userProfile; else userProfile = ppixiv.userCache.getUserProfile(this.userId); /\x2f Refresh with any data we just got. This usually fills in most of the dropdown quickly, /\x2f and we'll refresh for the rest. this.refresh(); /\x2f We only want to request follow info if we're following. If we already have user info, /\x2f request follow info, so we start this request earlier if we can. if(this.data.userInfo?.isFollowed) this._requestFollowInfo(); /\x2f If we had to request the user info or profile, wait for them to complete and refresh again. if(userInfo) this.data.userInfo = await userInfo; if(userProfile) this.data.userProfile = await userProfile; /\x2f Refresh again now that we have user info and the profile. this.refresh(); /\x2f In case we didn't have user info earlier, request follow info now. It's OK for us to /\x2f do this twice (the request won't be duplicated). if(this.data.userInfo?.isFollowed) this._requestFollowInfo(); /\x2f Request the user's Booth URL. This won't start until we have follow info, so there's no /\x2f benefit to doing this earlier. this._requestBoothInfo(); } /\x2f Request user follow info to find out if we're following publically or privately, and /\x2f refresh. async _requestFollowInfo() { let followInfo = await ppixiv.userCache.getUserFollowInfo(this.userId); this.data.followingPrivately = followInfo?.followingPrivately; this.refresh(); } /\x2f Request the user's Booth link and refresh. async _requestBoothInfo() { this.data.boothUrl = await ppixiv.userCache.getUserBoothUrl(this.userId); this.refresh(); } /\x2f Refresh the UI with as much data as we have. This data comes in a bunch of little pieces, /\x2f so we get it incrementally. refresh() { this.viewPosts.href = \`/users/\${this.userId}#ppixiv\`; let { followingPrivately=null, ...otherUserInfo } = this.data; if(!this.visible) return; let userInfo = ppixiv.userCache.getUserInfoSync(this.userId); this.viewPosts.querySelector(".label").textContent = userInfo?.name ?? ""; let infoLinksContainer = this.root; for(let link of this.querySelectorAll(".info-link, .separator")) link.remove(); if(userInfo) { let links = this._getInfoLinksForUser({userInfo, ...otherUserInfo}); links = this._filterLinks(links); for(let {url, label, type, icon, disabled} of links) { if(type == "separator") { let separator = document.createElement("div"); separator.classList.add("separator"); infoLinksContainer.appendChild(separator); continue; } let button = helpers.createBoxLink({ asElement: true, label, icon, link: url, classes: ["info-link"], }); if(disabled) button.classList.add("disabled"); if(type == "mute") { button.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); this._clickedMute(); }); } infoLinksContainer.appendChild(button); } } this.root.querySelector(".follow-button-public").hidden = true; this.root.querySelector(".follow-button-private").hidden = true; this.root.querySelector(".toggle-follow-button-public").hidden = true; this.root.querySelector(".toggle-follow-button-private").hidden = true; this.root.querySelector(".unfollow-button").hidden = true; this.root.querySelector(".follow-placeholder").hidden = true; let following = userInfo?.isFollowed; if(following != null) { if(following) { /\x2f If we know whether we're following privately or publically, we can show the /\x2f button to change the follow mode. If we don't have that yet, we can only show /\x2f unfollow. if(followingPrivately != null) { this.root.querySelector(".toggle-follow-button-public").hidden = !followingPrivately; this.root.querySelector(".toggle-follow-button-private").hidden = followingPrivately; } else { /\x2f If we don't know this yet, show a placeholder where the toggle button will go to /\x2f prevent other entries from shifting around as we load. this.root.querySelector(".follow-placeholder").hidden = false; } this.root.querySelector(".unfollow-button").hidden = false; } else { this.root.querySelector(".follow-button-public").hidden = false; this.root.querySelector(".follow-button-private").hidden = false; } } /\x2f If we've loaded follow tags, fill in the list. for(let element of this.root.querySelectorAll(".follow-tag")) element.remove(); } async _clickedFollow(followPrivately) { this.close(); await Actions.follow(this.userId, followPrivately); /\x2f The public/private follow state needs to be refreshed explicitly. this._requestFollowInfo(); } async _clickedUnfollow() { this.close(); /\x2f Confirm unfollowing when on mobile. if(ppixiv.mobile) { let userInfo = ppixiv.userCache.getUserInfoSync(this.userId); let result = await (new ConfirmPrompt({ header: userInfo? \`Unfollow \${userInfo.name}?\`:"Unfollow?" })).result; if(!result) return; } await Actions.unfollow(this.userId); } async _clickedMute() { if(ppixiv.muting.isUserIdMuted(this.userId)) ppixiv.muting.unmuteUserId(this.userId); else await ppixiv.muting.addMute(this.userId, null, {type: "user"}); } /\x2f Return info links for the given user. This is used by data sources with contents /\x2f related to a specific user. _getInfoLinksForUser({ userInfo, userProfile, boothUrl }={}) { if(userInfo == null) return []; let extraLinks = []; extraLinks.push({ url: new URL(\`/discovery/users#ppixiv?user_id=\${userInfo.userId}\`, ppixiv.plocation), type: "similar-artists", label: "Similar artists", }); extraLinks.push({ url: new URL(\`/users/\${userInfo.userId}/following#ppixiv\`, ppixiv.plocation), type: "following-link", label: \`View followed users\`, }); extraLinks.push({ url: new URL(\`/users/\${userInfo.userId}/bookmarks/artworks#ppixiv\`, ppixiv.plocation), type: "bookmarks-link", label: \`View bookmarks\`, }); extraLinks.push({ url: new URL(\`/messages.php?receiver_id=\${userInfo.userId}\`, ppixiv.plocation), type: "contact-link", label: "Send a message", }); let muted = ppixiv.muting.isUserIdMuted(userInfo.userId); extraLinks.unshift({ type: "mute", label: \`\${muted? "Unmute":"Mute"} this user\`, icon: "mat:block", }); if(userInfo?.acceptRequest) { extraLinks.push({ url: new URL(\`/users/\${this.userId}/request#no-ppixiv\`, ppixiv.plocation), type: "request", label: "Accepting requests", }); } /\x2f Add a separator before user profile links. extraLinks.push({ type: "separator" }); /\x2f Add entries from userInfo.social. let knownSocialKeys = { circlems: { label: "Circle.ms", }, }; let social = userInfo?.social ?? []; for(let [key, {url}] of Object.entries(social)) { let data = knownSocialKeys[key] ?? { }; data.label ??= helpers.strings.titleCase(key); extraLinks.push({ url, ...data }); } /\x2f Set the webpage link. /\x2f /\x2f If the webpage link is on a known site, disable the webpage link and add this to the /\x2f generic links list, so it'll use the specialized icon. let webpageUrl = userInfo?.webpage; if(webpageUrl != null) { extraLinks.push({ url: webpageUrl, label: "Webpage", type: this._findLinkImageType(webpageUrl) ?? "webpage-link", }); } /\x2f Find any other links in the user's profile text. let div = document.createElement("div"); div.innerHTML = userInfo.commentHtml; for(let link of div.querySelectorAll("a")) { let url = helpers.pixiv.fixPixivLink(link.href); try { url = new URL(url); } catch(e) { console.log("Couldn't parse profile URL:", url); continue; } /\x2f Figure out a label to use. let label = url.hostname; let imageType = this._findLinkImageType(url); if(imageType == "booth") label = "Booth"; else if(imageType == "fanbox") label = "Fanbox"; else if(label.startsWith("www.")) label = label.substr(4); extraLinks.push({ url, label, }); } /\x2f See if there's a Fanbox link. /\x2f /\x2f For some reason Pixiv supports links to Twitter and Pawoo natively in the profile, but Fanbox /\x2f can only be linked in this weird way outside the regular user profile info. let pickups = userProfile?.body?.pickup ?? []; for(let pickup of pickups) { if(pickup.type != "fanbox") continue; /\x2f Remove the Google analytics junk from the URL. let url = new URL(pickup.contentUrl); url.search = ""; extraLinks.push({url, type: "fanbox", label: "Fanbox"}); break; } /\x2f Add the Booth link if we have one. If we know there will be one but it's still loading, /\x2f add a placeholder so the menu doesn't move around when it finishes loading. if(boothUrl) extraLinks.push({url: boothUrl, label: "Booth"}); else if(userProfile?.body?.externalSiteWorksStatus?.booth) extraLinks.push({url: window.location, label: "Booth", icon: "mat:hourglass_full", disabled: true}); /\x2f Allow hooks to add additional links. window.vviewHooks?.addUserLinks?.({ extraLinks, userInfo, userProfile }); return extraLinks; } /\x2f Fill in link icons and remove duplicates. _filterLinks(extraLinks) { /\x2f Map from link types to icons: let linkTypes = { /\x2f Generic types: ["default-icon"]: "ppixiv:link", ["shopping-cart"]: "mat:shopping_cart", ["webpage-link"]: "mat:home", ["commercial"]: "mat:paid", /\x2f Site-specific ones. The distinction is mostly arbitrary, but this tries to /\x2f use mat:shopping_cart for sites where you purchase something specific, like /\x2f Booth and Amazon, and mat:paid for other types of paid things, like subscriptions /\x2f and commissions. ["posts"]: "mat:palette", ["twitter"]: "ppixiv:twitter", ["fanbox"]: "mat:paid", ["request"]: "mat:paid", ["booth"]: "mat:shopping_cart", ["twitch"]: "ppixiv:twitch", ["contact-link"]: "mat:mail", ["following-link"]: "mat:visibility", ["bookmarks-link"]: "mat:star", ["similar-artists"]: "ppixiv:suggestions", ["mute"]: "block", }; /\x2f Sort let filteredLinks = []; let seenLinks = {}; let seenTypes = {}; for(let {type, url, label, ...other} of extraLinks) { if(type == "separator") { filteredLinks.push({ type }); continue; } /\x2f Filter duplicate links. if(url && seenLinks[url]) continue; seenLinks[url] = true; /\x2f Filter out entries with invalid URLs. if(url) { try { url = new URL(url); } catch(e) { console.log("Couldn't parse profile URL:", url); continue; } } /\x2f Guess link types that weren't supplied. type ??= this._findLinkImageType(url); type ??= "default-icon"; /\x2f A lot of users have links duplicated in their profile and profile text. if(seenTypes[type] && type != "default-icon" && type != "shopping-cart" && type != "webpage-link") continue; seenTypes[type] = true; /\x2f Fill in the icon. let icon = linkTypes[type]; /\x2f If this is a Twitter link, parse out the ID. We do this here so this works /\x2f both for links in the profile text and the profile itself. if(type == "twitter") { let parts = url.pathname.split("/"); label = parts.length > 1? ("@" + parts[1]):"Twitter"; } filteredLinks.push({ url, type, icon, label, ...other }); } /\x2f Remove the last entry if it's a separator with nothing to separate. if(filteredLinks.length && filteredLinks[filteredLinks.length-1].type == "separator") filteredLinks.splice(filteredLinks.length-1, 1); return filteredLinks; } _findLinkImageType(url) { url = new URL(url); let altIcons = { "shopping-cart": [ "dlsite.com", "skeb.jp", "ko-fi.com", "dmm.co.jp", ], "commercial": [ "fantia.jp", ], "twitter": [ "twitter.com", ], "fanbox": [ "fanbox.cc", ], "booth": [ "booth.pm", ], "twitch": [ "twitch.tv", ], }; /\x2f Special case for old Fanbox URLs that were under the Pixiv domain. if((url.hostname == "pixiv.net" || url.hostname == "www.pixiv.net") && url.pathname.startsWith("/fanbox/")) return "fanbox"; for(let alt in altIcons) { /\x2f "domain.com" matches domain.com and *.domain.com. for(let domain of altIcons[alt]) { if(url.hostname == domain) return alt; if(url.hostname.endsWith("." + domain)) return alt; } } return null; } }; /\x2f A dropdown to select follow tags. This is in a separate submenu, since it /\x2f needs to be loaded separately and it causes the top user menu to move around /\x2f too much. This also makes more sense if the user has lots of follow tags. class FollowTagWidget extends Widget { constructor({ userId, ...options }) { super({ ...options, template: \`
\` }); this.userId = userId; this.load(); /\x2f Refresh if our user changes, so we update tag highlights as they're edited. ppixiv.userCache.addEventListener("usermodified", ({userId}) => { if(userId == this.userId) this.load(); }, this._signal); } async load() { /\x2f Get user info to see if we're followed, get our full follow tag list, and any /\x2f tags this user is followed with. This will all usually be in cache. let userInfo = await ppixiv.userCache.getUserInfo(this.userId); let selectedTags = new Set(); if(userInfo?.isFollowed) { let followInfo = await ppixiv.userCache.getUserFollowInfo(this.userId); selectedTags = followInfo.tags; } let allTags = await ppixiv.userCache.loadAllUserFollowTags(); let followTagList = this.root; helpers.html.removeElements(followTagList); let addTagButton = helpers.createBoxLink({ label: "Add new tag", icon: "add_circle", classes: ["follow-tag"], asElement: true, }); addTagButton.addEventListener("click", (e) => this._addFollowTag()); followTagList.appendChild(addTagButton); allTags.sort((lhs, rhs) => lhs.toLowerCase().localeCompare(rhs.toLowerCase())); for(let tag of allTags) { let button = helpers.createBoxLink({ label: tag, classes: ["follow-tag"], icon: "bookmark", asElement: true, }); /\x2f True if the user is bookmarked with this tag. let selected = selectedTags.has(tag); helpers.html.setClass(button, "selected", selected); followTagList.appendChild(button); button.addEventListener("click", (e) => { this._toggleFollowTag(tag); }); } } async _addFollowTag() { let prompt = new TextPrompt({ title: "New folder:" }); let folder = await prompt.result; if(folder == null) return; /\x2f cancelled await this._toggleFollowTag(folder); } async _toggleFollowTag(tag) { /\x2f Make a copy of userId, in case it changes while we're async. let userId = this.userId; /\x2f If the user isn't followed, the first tag is added by following. let userData = await ppixiv.userCache.getUserInfo(userId); if(!userData.isFollowed) { /\x2f We're not following, so follow the user with default privacy and the /\x2f selected tag. await Actions.follow(userId, null, { tag }); return; } /\x2f We're already following, so update the existing tags. let followInfo = await ppixiv.userCache.getUserFollowInfo(userId); if(followInfo == null) { console.log("Error retrieving follow info to update tags"); return; } let tagWasSelected = followInfo.tags.has(tag); Actions.changeFollowTags(userId, {tag: tag, add: !tagWasSelected}); } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/user-widgets.js `), "/vview/widgets/whats-new.js": loadBlob("application/javascript", `import { helpers } from '/vview/misc/helpers.js'; import widget from '/vview/widgets/widget.js'; let updateHistory = [ { version: 229, text: \` Select "Translate" from the context menu while viewing an image to enable translation using Cotrans (experimental).

Added support for viewing Pixiv image series. \`, }, { version: 218, text: \` Added support for the "Hide AI works" filter on searches. \`, }, { version: 210, text: \` Aspect ratio thumbnails are now used by default. Square thumbs can be selected in settings. \`, }, { version: 198, text: \` Artist links have been moved to the avatar dropdown, and can be accessed directly from the popup menu.

"AI-generated" is now displayed as a tag. \`, }, { version: 172, text: \` Added support for AI rankings. \`, }, { version: 168, text: \` Images tagged as "AI" are now marked in search results. There are too many of these flooding the site, but this gives an alternative to muting them.

Slideshows are now limited to 60 FPS by default. (Why?) This can be disabled in settings.

\`, }, { version: 164, boring: true, text: \` Search autocomplete now works for searches with multiple tags. \`, }, { version: 162, text: \` Search tags can now be saved in the search dropdown separately from recents and grouped together.

Added "Loop" in the more options dropdown to loop the current image. \`, }, { version: 153, boring: true, text: \` Pressing Ctrl-S now saves the current image or video, and Ctrl-Alt-S saves a ZIP of the current manga post.

Fixed hotkeys in search results, so hotkeys like Ctrl-B work when hovering over thumbnails. \`, }, { version: 152, boring: true, text: \` Tags that have been searched for recently now appear at the top of the artist tag list. \`, }, { version: 151, boring: true, text: \` Navigating through images with the mousewheel now skips past manga pages if the image is muted. \`, }, { version: 145, text: \` Added support for viewing followed users who are accepting requests, and a link from the user to the request page. This feature is still being rolled out by Pixiv and may not be available for all users immediately. \`, }, { version: 142, boring: true, text: \` The slideshow can now be set to fade through images without panning.

Thumbnail panning now stops after a while if there's no mouse movement, so it doesn't keep going forever. \`, }, { version: 139, text: \` Added a panning/slideshow editor, to edit how an image will pan and zoom during slideshows. Right-click and enable \${ helpers.createIcon("settings") } \${ helpers.createIcon("brush") } Image Editing, then \${ helpers.createIcon("wallpaper") } Edit Panning while viewing an image.

Added a button to \${ helpers.createIcon("restart_alt") } Refresh the search from the current page. The \${ helpers.createIcon("refresh") } Refresh button now always restarts from the beginning. \`, }, { version: 133, text: \` Pressing Ctrl-P now toggles image panning.

Added image cropping for trimming borders from images. Enable \${ helpers.createIcon("settings") } Image Editing in the context menu to display the editor.

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

You can now view manga posts in search results. Click the page count in the corner of thumbnails to show all manga pages. You can also click \${ helpers.createIcon("open_in_full") } in the top menu to expand everything, or turn it on everywhere in settings. \`, }, { version: 126, text: \` Muted tags and users can now be edited from the preferences menu.

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

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

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

Double-clicking images now toggles fullscreen.

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

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

Reworked the animation UI. \`, }, { version: 117, text: \` Added Linked Tabs. Enable linked tabs in preferences to show images on more than one monitor as they're being viewed (try it with a portrait monitor).

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

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

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

This can be enabled in preferences, and may become the default in a future release. \` }, { version: 109, boring: true, text: \`Added a visual marker on thumbnails to show the last image you viewed.\` }, { version: 104, text: \` Bookmarks can now be shuffled, to view them in random order.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Hiding the mouse cursor can now be disabled in preferences. \` }, { version: 49, text: \` Add \\"Hover to show UI\\" preference, which is useful for low-res monitors. \` }, { version: 47, text: \` You can now view the users you're following with \\"Followed Users\\". This shows each user's most recent post. \` }, ]; export default class WhatsNew extends widget { /\x2f Return the newest revision that exists in history. This is always the first /\x2f history entry. static latestHistoryRevision() { return updateHistory[0].version; } /\x2f Return the latest interesting history entry. /\x2f /\x2f We won't highlight the "what's new" icon for boring history entries. static latestInterestingHistoryRevision() { for(let history of updateHistory) { if(history.boring) continue; return history.version; } /\x2f We shouldn't get here. throw Error("Couldn't find anything interesting"); } /\x2f Set html[data-whats-new-updated] for highlights when there are What's New updates. /\x2f This updates automatically if whats-new-last-viewed-version is updated. static handleLastViewedVersion() { let refresh = () => { let lastViewedVersion = ppixiv.settings.get("whats-new-last-viewed-version", 0); /\x2f This was stored as a string before, since it came from GM_info.script.version. Make /\x2f sure it's an integer. lastViewedVersion = parseInt(lastViewedVersion); let newUpdates = lastViewedVersion < WhatsNew.latestInterestingHistoryRevision(); helpers.html.setDataSet(document.documentElement.dataset, "whatsNewUpdated", newUpdates); }; refresh(); ppixiv.settings.addEventListener("whats-new-last-viewed-version", refresh); } constructor({...options}={}) { super({...options, dialogClass: "whats-new-dialog", header: "Updates", template: \`

\`}); this.root.addEventListener("click", this.onclick); ppixiv.settings.set("whats-new-last-viewed-version", WhatsNew.latestHistoryRevision()); this.refresh(); } onclick = (e) => { let explanationButton = e.target.closest(".explanation-button"); if(explanationButton) { e.preventDefault(); e.stopPropagation(); let name = e.target.dataset.explanation; let target = this.root.querySelector(\`.\${name}\`); target.hidden = false; } } refresh() { let itemsBox = this.root.querySelector(".contents"); for(let node of itemsBox.querySelectorAll(".item")) node.remove(); let githubTopURL = "https:/\x2fgithub.com/ppixiv/ppixiv/"; for(let idx = 0; idx < updateHistory.length; ++idx) { let update = updateHistory[idx]; let previousUpdate = updateHistory[idx+1]; let entry = this.createTemplate({name: "item", html: \`
\`}); let rev = entry.querySelector(".rev"); rev.innerText = "r" + update.version; /\x2f Link to the change list between this revision and the next revision that has release notes. let previousVersion = previousUpdate? ("r" + previousUpdate.version):"r1"; rev.href = \`\${githubTopURL}/compare/\${previousVersion}...r\${update.version}\`; entry.querySelector(".text").innerHTML = update.text; itemsBox.appendChild(entry); } } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/whats-new.js `), "/vview/widgets/widget.js": loadBlob("application/javascript", `/\x2f A basic widget base class. import Actor from '/vview/actors/actor.js'; import { helpers } from '/vview/misc/helpers.js'; export default class Widget extends Actor { /\x2f Find the widget containing a node. static fromNode(node, { allowNone=false }={}) { if(node == null && allowNone) return null; /\x2f The top node for the widget has the widget class. let widgetTopNode = node.closest(".widget"); if(widgetTopNode == null) { if(allowNone) return null; console.log("Node wasn't in a widget:", node); throw new Error("Node wasn't in a widget:", node); } console.assert(widgetTopNode.widget != null); return widgetTopNode.widget; } constructor({ container, template=null, visible=true, parent=null, /\x2f An insertAdjacentElement position (beforebegin, afterbegin, beforeend, afterend) indicating /\x2f where our contents should be inserted relative to container. This can also be "replace", which /\x2f will replace container. containerPosition="beforeend", ...options}={}) { /\x2f If container is a widget instead of a node, use the container's root node. if(container != null && container instanceof Widget) container = container.root; if(parent == null) { let parentSearchNode = container; if(parentSearchNode == null && parent == null) console.warn("Can't search for parent"); if(parentSearchNode) { let parentWidget = Widget.fromNode(parentSearchNode, { allowNone: true }); if(parent != null && parent !== parentWidget) { console.assert(parent === parentWidget); console.log("Found:", parentWidget); console.log("Expected:", parent); } parent = parentWidget; } } super({container, parent, ...options}); this.root = this.createTemplate({html: template}); if(container != null) { if(containerPosition == "replace") container.replaceWith(this.root); else container.insertAdjacentElement(containerPosition, this.root); } this.root.classList.add("widget"); this.root.dataset.widget = this.className; this.root.widget = this; /\x2f Set _visible without calling applyVisibility. We'll do that in afterInit so it /\x2f happens after the subclass is constructed. this._visible = visible; helpers.other.defer(() => { if(this.hasShutdown) return; this.afterInit(); }); } /\x2f This is called asynchronously after construction, and can be used for initialization /\x2f that should happen after the subclass is fully set up. afterInit() { this.applyVisibility(); this.visibilityChanged(); this.refresh(); } /\x2f Use widget.root instead of widget.container. get container() { console.warn("Deprecated widget.container"); return this.root; } async refresh() { } /\x2f Set whether the widget should be visible. /\x2f /\x2f This is usually only set by a widget's parent and not the widget itself, and tells us /\x2f whether we should be visible. The widget may not become visible or hidden immediately /\x2f if it's animated. /\x2f /\x2f This only knows about this actor. To find out if an actor and all of its ancestors are /\x2f visible, use visibleRecursively. get visible() { return this._visible; } set visible(value) { if(value == this.visible) return; this._visible = value; this.callVisibilityChanged(); } /\x2f Return true if this widget is actually visible in the document. If visible is false but /\x2f we're still animating away, we're actually still visible until the animation finishes. /\x2f /\x2f This only knows about this actor. To find out if an actor and all of its ancestors are /\x2f actually visible, use actuallyVisibleRecursively. get actuallyVisible() { return this.visible; } visibilityChanged() { super.visibilityChanged(); this.applyVisibility(); } shutdown() { super.shutdown(); this.root.remove(); } /\x2f Show or hide the widget. /\x2f /\x2f By default the widget is visible based on the value of this.visible, but the /\x2f subclass can override this. applyVisibility() { helpers.html.setClass(this.root, "hidden-widget", !this._visible); } /\x2f This is called (via callVisibilityChanged) when visible, actuallyVisible or their recursive /\x2f versions may have changed value. visibilityChanged() { super.visibilityChanged(); this.applyVisibility(); if(this.actuallyVisible) { /\x2f Create an AbortController that will be aborted when the widget is hidden. if(this.visibilityAbort == null) this.visibilityAbort = new AbortController; } else { if(this.visibilityAbort) this.visibilityAbort.abort(); this.visibilityAbort = null; } } querySelector(selector) { return this.root.querySelector(selector); } querySelectorAll(selector) { return this.root.querySelectorAll(selector); } closest(selector) { return this.root.closest(selector); } /\x2f Return an array of all DOM roots within this tree. This is a list of DOM nodes which /\x2f contain all DOM nodes within the widget. /\x2f /\x2f Most of the time, a widget's only DOM root is its own root. However, if a widget /\x2f contains a dropdown or other type of child widget which lives somewhere else in the /\x2f tree, that's also a root. /\x2f /\x2f This allows detecting if things like pointer events are anywhere within a widget's tree. getRoots() { let result = [this.root]; /\x2f Any node whose root isn't within its parent widget's root is a new root node, /\x2f since it's not a DOM descendant of its parent. for(let widget of this.descendents()) { /\x2f Skip non-widget actors. if(widget.root == null) continue; if(helpers.html.isAbove(widget.parent.root, widget.root)) result.push(widget.root); } return result; } } /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/widget.js `), "/vview/widgets/zip-image-player.js": loadBlob("application/javascript", `import { helpers } from '/vview/misc/helpers.js'; import ZipImageDownloader from '/vview/misc/zip-image-downloader.js'; /\x2f This gives a small subset of HTMLVideoPlayer's API to control the video, so /\x2f VideoUI can work with this in the same way as a regular video. class ZipVideoInterface extends EventTarget { constructor(player) { super(); this.player = player; } get paused() { return this.player.paused; } get duration() { /\x2f Expose the seekable duration rather than the full duration, since it looks /\x2f weird if you seek to the end of the seek bar and the time isn't at the end. /\x2f /\x2f Some crazy person decided to use NaN as a sentinel for unknown duration instead /\x2f of null, so mimic that. let result = this.player.getSeekableDuration(); if(result == null) return NaN; else return result; } get currentTime() { return this.player.getCurrentFrameTime(); } play() { return this.player.play(); } pause() { return this.player.pause(); } hideAudioControls() { return true; } } export default class ZipImagePlayer { constructor(options) { this.op = options; this.interface = new ZipVideoInterface(this); /\x2f If true, continue playback when we get more data. this.waitingForFrame = true; this.dead = false; this.context = options.canvas.getContext("2d"); /\x2f The frame that we want to be displaying: this.frame = 0; this.failed = false; /\x2f These aren't available until load() completes. this.frameTimestamps = []; this.totalLength = 0; this.frameCount = 0; this.seekableLength = null; this.frameData = []; this.frameImages = []; this.speed = 1; this.paused = !this.op.autoStart; this.load(); } error(msg) { this.failed = true; throw Error("ZipImagePlayer error: " + msg); } async load() { this.downloader = new ZipImageDownloader(this.op.source, { signal: this.op.signal, }); if(this.op.local) { /\x2f For local files, the first file in the ZIP contains the metadata. let data; try { data = await this.downloader.getNextFrame(); } catch(e) { /\x2f This will usually be cancellation. console.info("Error downloading file", e); return; } /\x2f Is there really no "decode databuffer to string with encoding" API? data = new Uint8Array(data); data = String.fromCharCode.apply(null, data); data = JSON.parse(data); this.frameMetadata = data; } else { this.frameMetadata = this.op.metadata.frames; } /\x2f Make a list of timestamps for each frame. this.frameTimestamps = []; let milliseconds = 0; let lastFrameTime = 0; for(let frame of this.frameMetadata) { this.frameTimestamps.push(milliseconds); milliseconds += frame.delay; lastFrameTime = frame.delay; } this.totalLength = milliseconds; this.frameCount = this.frameMetadata.length; /\x2f The duration to display on the seek bar. This doesn't include the duration of the /\x2f final frame. We can't seek to the actual end of the video past the end of the last /\x2f frame, and the end of the seek bar represents the beginning of the last frame. this.seekableLength = milliseconds - lastFrameTime; let frame = 0; while(1) { let file; try { file = await this.downloader.getNextFrame(); } catch(e) { /\x2f This will usually be cancellation. if(e.name != "AbortError") console.info("Error downloading file", e); return; } if(file == null) break; /\x2f Read the frame data into a blob and store it. /\x2f /\x2f Don't decode it just yet. We'll decode it the first time it's displayed. This way, /\x2f we read the file as it comes in, but we won't burst decode every frame right at the /\x2f start. This is important if the video ZIP is coming out of cache, since the browser /\x2f can't cache the image decodes and we'll cause a big burst of CPU load. let mimeType = this.op.metadata?.mime_type || "image/jpeg"; let blob = new Blob([file], {type: mimeType}); this.frameData.push(blob); /\x2f Call progress. This is relative to frame timestamps, so load progress lines up /\x2f with the seek bar. if(this.op.progress) { let progress = this.frameTimestamps[frame] / this.totalLength; this.op.progress(progress); } frame++; /\x2f We have more data to potentially decode, so start _decodeFrames if it's not already running. this._decodeFrames(); /\x2f Throttle decoding in case we're getting video data very quickly, so if we get the whole /\x2f file at once from cache or a local server we don't chew CPU decoding it all at once. If /\x2f the data is streaming from a server, this small delay won't have any effect. await helpers.other.sleep(1); } /\x2f Call completion. if(this.op.progress) this.op.progress(null); } /\x2f Load the next frame into this.frameImages. async _decodeFrames() { /\x2f If this is already running, don't start another. if(this.loadingFrames) return; try { this.loadingFrames = true; while(await this._decodeOneFrame()) { } } finally { this.loadingFrames = false; } } /\x2f Decode up to one frame ahead of this.frame, so we don't wait until we need a /\x2f frame to start decoding it. Return true if we decoded a frame and should be /\x2f called again to see if we can decode another. async _decodeOneFrame() { let ahead = 0; for(ahead = 0; ahead < 2; ++ahead) { let frame = this.frame + ahead; /\x2f Stop if we don't have data for this frame. If we don't have this frame, we won't /\x2f have any after either. let blob = this.frameData[frame]; if(blob == null) return; /\x2f Skip this frame if it's already decoded. if(this.frameImages[frame]) continue; let url = URL.createObjectURL(blob); let image = document.createElement("img"); image.src = url; await helpers.other.waitForImageLoad(image); URL.revokeObjectURL(url); this.frameImages[frame] = image; /\x2f If we were stalled waiting for data, display the frame. It's possible the frame /\x2f changed while we were blocking and we won't actually have the new frame, but we'll /\x2f just notice and turn waitingForFrame back on. if(this.waitingForFrame) { this.waitingForFrame = false; this._displayFrame(); } if(this.dead) return false; return true; } return false; } async _displayFrame() { if(this.dead) return; this._decodeFrames(); /\x2f If we don't have the frame yet, just record that we want to be called when the /\x2f frame is decoded and stop. _decodeFrames will call us when there's a frame to display. if(!this.frameImages[this.frame]) { /\x2f We haven't downloaded this far yet. Show the frame when we get it. this.waitingForFrame = true; return; } let image = this.frameImages[this.frame]; if(this.op.autosize) { if(this.context.canvas.width != image.width || this.context.canvas.height != image.height) { /\x2f make the canvas autosize itself according to the images drawn on it /\x2f 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.drawnFrame = this.frame; this.context.clearRect(0, 0, this.op.canvas.width, this.op.canvas.height); this.context.drawImage(image, 0, 0); this.videoInterface.dispatchEvent(new Event("timeupdate")); if(this.paused) return; let meta = this.frameMetadata[this.frame]; this.pendingFrameMetadata = meta; this._refreshTimer(); } _unsetTimer() { if(!this.timer) return; realClearTimeout(this.timer); this.timer = null; } _refreshTimer() { if(this.paused) return; this._unsetTimer(); this.timer = realSetTimeout(this._nextFrame, this.pendingFrameMetadata.delay / this.speed); } _getFrameDuration() { let meta = this.frameMetadata[this.frame]; return meta.delay; } _nextFrame = (frame) => { this.timer = null; if(this.frame >= (this.frameCount - 1)) { if(!this.op.loop) { this.pause(); if(this.op.onfinished) this.op.onfinished(); return; } this.frame = 0; } else { this.frame += 1; } this._displayFrame(); } play() { if(this.dead) return; if(this.paused) { this.paused = false; this._displayFrame(); this.videoInterface.dispatchEvent(new Event("play")); } } pause() { if(this.dead) return; if(!this.paused) { this._unsetTimer(); this.paused = true; this.videoInterface.dispatchEvent(new Event("pause")); } } _setPause(value) { if(this.dead) return; if(this.paused = value) return; this.context.canvas.paused = this.paused; this.paused = value; } get videoInterface() { return this.interface; } togglePause() { if(this.paused) this.play(); else this.pause(); } rewind() { if(this.dead) return; this.frame = 0; this._unsetTimer(); this._displayFrame(); } setSpeed(value) { this.speed = value; /\x2f Refresh the timer, so we don't wait a long time if we're changing from a very slow /\x2f playback speed. this._refreshTimer(); } stop() { this.dead = true; this._unsetTimer(); this.frameImages = null; } getCurrentFrame() { return this.frame; } setCurrentFrame(frame) { frame %= this.frameCount; if(frame < 0) frame += this.frameCount; this.frame = frame; this._displayFrame(); } getTotalDuration() { return this.totalLength / 1000; } getSeekableDuration() { if(this.seekableLength == null) return null; else return this.seekableLength / 1000; } getCurrentFrameTime() { let timestamp = this.frameTimestamps[this.frame]; return timestamp == null? null: timestamp / 1000; } /\x2f Set the video to the closest frame to the given time. setCurrentFrameTime(seconds) { /\x2f We don't actually need to check all frames, but there's no need to optimize this. let closestFrame = null; let closestError = null; for(let frame = 0; frame < this.frameMetadata.length; ++frame) { /\x2f Only seek to images that we've downloaded. If we reach a frame we don't have /\x2f yet, stop. if(!this.frameData[frame]) break; let error = Math.abs(seconds - this.frameTimestamps[frame]/1000); if(closestFrame == null || error < closestError) { closestFrame = frame; closestError = error; } } this.frame = closestFrame; this._displayFrame(); } getFrameCount() { return this.frameCount; } } /* * 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. */ /\x2f# sourceURL=https:/\x2fraw.githubusercontent.com/ppixiv/ppixiv/r231/web/vview/widgets/zip-image-player.js `), }; env.resources["resources/activate-icon.png"] = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAC4AAAAsCAYAAAAacYo8AAAACXBIWXMAAC4jAAAuIwF4pT92AAAGU2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0yN1QwMjoyMjoyOS0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDYtMjdUMDI6MjY6MjAtMDU6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTgtMDYtMjdUMDI6MjY6MjAtMDU6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MWU4MmI3MjgtOTVjNi1mNzQyLWJjOWQtMjIwMTM5NzJkNDBlIiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6N2ZkYzUwY2ItYjgzMy1hNzQzLTllMjYtNzQ1NmM4NDFlNjM0IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6MzMyMzRmNjktNjk2OS1jNjQ1LWI0MjgtYmM1NDUwYTM3NDAzIj4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDozMzIzNGY2OS02OTY5LWM2NDUtYjQyOC1iYzU0NTBhMzc0MDMiIHN0RXZ0OndoZW49IjIwMTgtMDYtMjdUMDI6MjI6MjktMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNvbnZlcnRlZCIgc3RFdnQ6cGFyYW1ldGVycz0iZnJvbSBhcHBsaWNhdGlvbi92bmQuYWRvYmUucGhvdG9zaG9wIHRvIGltYWdlL3BuZyIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6MWU4MmI3MjgtOTVjNi1mNzQyLWJjOWQtMjIwMTM5NzJkNDBlIiBzdEV2dDp3aGVuPSIyMDE4LTA2LTI3VDAyOjI2OjIwLTA1OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDwvcmRmOlNlcT4gPC94bXBNTTpIaXN0b3J5PiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PmQ/KUAAAAQhSURBVFiF7ZlNTxtHGMd/s/aaGGqDHRRLwQergko+lJhW4pJDqdQLpzSfoOHqU/sJGj5By8VXkk8QcuqlUtxjckBWLxxaqVYFVQnCdWiDYy/e6WG8Zr07a68X8xIpfwmtZp6Z2d8+fuaZF4SUkvdRxnUDRNV7Cx4HEEJMbsRKpwTMAc7TUb3316ScqEUd3gltIaW8GHil8zXwBbCGgg2rKvAc2KGcqIftdDHwSmcN+AZ4NF7HQO0AW5QT1VENo4FXOo+A74GCzizaDWg3EN1TOGsNGuNJZOI2cioL8eSwD9ignGhOBnwIsGgdIt4dIlqvwbaGj+O8fCqLTC+pj/Cr2YPfiQ6uJtoPqPj1ARtv9vyeHUMymcPOfAqGqTNvUE488fUZCV7pPEZ52SfRbmAcvYwMPKB4Evv2Z0gzrbP64IPBK5054BkaLzsKAy6TOWR60Qek7WuYdO/cD4r9FXf6dMDjA01UaLxgMP82PeXhwDML2JnlfvnjuzESpqBjSQ6ObNpk6ebXVagd76pGtoVxvIudu68bchtY8Vaer5xqAnqha8BGWOhufh07s0wmZbBaNFktmszPGqSnBfOzBvcW46wWVTzLZA45s9DvK6wTxH913bClHpsGXBm2PdA7lBMrKI8PB777Fd38er+8lI8Nbe/A25nlAS8bJ78HdfHNNcfj2576GiE93c2vg2EiWodhmvf1+Sc9z7vngG0h3h7omhd6K3RfDvimq64JPBy2CPjUi9FxFHP9KO45Id4FOuCBu+CA/4jaAAFUx9k7GEcvEda/YZtrNRDr7UZQs9LAewF63nVCY26cl040p4NaffUrcMldOM8qaoOzCTydHEVIeVbfML/gYB4vJx5PkiesjDd74RqqdaYG13gCerWnwkG0G+EzkmsFvRZwBxrb0s4PaaZGjhEf2SKCXu1ZTN8SLMwbZFLnvvnzsMvfDbtfjv31s7+zYQbtFuvuwqWAG//8yulskd/2TaDrtx/vBoaHTN4JGrbqLlwKuHh7QEy/Ao6UnM4HmZ67CzfqekJOZYNORXXviehSwN0brnEk54pBpk1vxURDJbb/U+S+dmY56BRU1R3hbkSoyI8KA/sVl5rAQ53hUiZnaBkm9mxxGPSXQbvUSODSTGGnlzBO9yOf8uXMAnZqKeicWUdtrWtB/aN53DCR6UW66UWEdQKt1+p51lLPINiprDqy3cpd6FIoOrgbxkyDmcZ7yy6sE7DPem1SQauhW3Xgu6CLIK+igteALdSpZA3NHj4gQ+i0AzwNC+woKnizl6KeAM4laAm4h7qmK6E/kDRRH10DfkGlumYUgMlkFXUIqU5krJC6EXk8ij6AX7WGg6sL0AcaS6E3Ia9Nozz+B/Ctpr4AvKDS0dmuRKOyytYIe21CHGNLfPjP8hXrf5SZd4NRInfBAAAAAElFTkSuQmCC"; env.resources["resources/ai.png"] = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACcAAAAbCAYAAAD/G5bjAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAABIAAAASABGyWs+AAAJiklEQVRYw52XeXBV1R3HP+fe+/Yl+0JADBpACagQAwyM2AHtiEXrvtXaTseMY6fqUDvjtFJt1dEqgy1D1Tpu1bozCm4BF1CpGwYQZEvAsASSELK+Je++d9+799c/bnxJjNhOz8yZ97u/d37nfM9vP0pEONH4es1Lcs9Dq/l/xiFN/qd14+dNYNl1P6J+5uXqu/8ZJxL6aMuzsnDpcuSwz2UEbUjpoxcF7bGCIWsMSwW1sbyAFzEtblp8HfWn138vhu8F1/TVa7Kwc+UwsBKYWD2Fhpuuz68ZyDSTtSaOksulXBAD6mie52SiJO10/tuyB/J0PHEMI1AGKozkLFGGd5T2xoBr+uo1mbf9fiQggHK1o1lUlYZY1nAxCTNLLG0xoWBJXqYnYxE1PHg9OjbgODbpjEMk4BkG6QiiuWd/q/+83rODKMkC3lFYRum7vbtFLvYvJzcDVGj4EiqokRr0A7B+03b+cP/LoGn5efPvnuaNjdvyB+9rPc4F195Hwszm93h69SaWLV/DsGOYXHfH/TTv7wRPCJzMWBOKSH7efewOUfF6mdQxS/h4psAsoWSWMHuqTKy7UgZtR0RELDsnORkeg7Yjlj3MseycHOlLip0b5sVTlvT2J2XkOHCkR8x0xv1I98pILCIy2qzeUAwE5oyDg+1DmjNt9JoQbe+2suiWW4hGKvHbXXkZvzZu1GXnzDyNpVddyviCIEpLs+LFDzhwwKS4wN3PVAcJe/rx+EopjriGvWLJ9ZTh/+GAKPcFIA3dKAhLPhr1QsG5Eb6Ivw/NJnQGXYFxKagKuHRIQVOKth2L+O01l9H44S5m1ZayIfY86zq2Q2yEr+9PuXuMS8HkIAsuWkSZOu2HwU3qq4Qg7ESgzIAA0AvZiCDlDvrGCI9tC3Lu+TYFf17MXm0aDzU1ss5sgSr38NQXfmzgnU/3UFk6m8l1sK4oiL9YI9cOpX/XuWdHgHnLwyTOu4RnWlppO5aldmIIYCgKTxCtErTpTuhwMIcKZ5BeH1LuwCaHx7bZNNgKfhWF2b+mgnLOrqlj4RsNbAllkHF+BsxeMo7wyLKrAHhhzzgmVewDHJLLQ6zba3LW2SbceAFEbmfO6RYd/QexbAuvMtwgGwnOSfTIIyvvZeneT6AP1KEc0uYgKTfPqeMatZ8YNNgOlmbhZcisBAgXnsq9dZO4uXsvKqSRAwzbobHpG04ujjK3vIoWM8fxjV7qj6U46zvKUIaXytI4WaeF47sMCqtKxFtarvKp5OWv7+Lzn3+KbBNYD06zGlUNtCOKaY7jBo3jRf55EBL/wJYmYD2T2juZFs0xsRbMVAfxXJaejn4OHe5kWnQRNWe4pey8uPtrbTOQZ99DeAo4jJ58ndjW59liatTOuoJjx48JgLF16yfy1vzNlBwFp1pBN6hwBq3cj10i0DQ2/WTXF+G55jm0xe8hu9LEvvJw2vMaRG2aAxa5xAA3XDoXgN3WVmZ6FJ1T4YOooqHfvaB1WwbProeR6Y/CrjQp5wLK7i3gm9Qgl97wez5843Ex1nevpRvFeMO9Vdn1UHuFl1AgQ3WFl08edAHtXgRPrBEabIXX8UJjBdb6DKDYcJnOWZPBHHD3eKrxBaaMW0CoMEpZ/QaieDhvgbC8SrG9H85C3D0ed2PA0hSHVi3gzIoc9Su8JALb2bnnLfTxt5b9ace+AaZFFZ+9n6W2Vmfx1V6MSoPeQuHgEUH/Gm74q2LVq2ECgxlmqCy66OwUg39dbuB9Bsq9QtAPRWcLR61t7M+9SXvFGnyVu8miKPRDaI5i+WuKeSZUDlnB0iyeqK3izMfu4CQjRdcZq1HTBT2Ywkj3K9r+qBP/m4Me8rClV2iSLCqlI0GgQKhFMXmC0PDZII88qPHuliluNP20mdm/CVAStogPHTa9DvQ6LzYWkCU2VCFjQHUdzP8Mrr9Tp3aDuz55MVQ/HOZqx4tNiEYRNg9kaM0OuNEqCYeoT7nJdoRvhTVFrlij94DOIDaTJwhLV2VJ0koYB69chIcgg3YnWSeIR0thqn2j/NPEj0bUzYEcoqammOLVXcRtN+DKMIjqh+mydlNKHTNj49ms7aMilMEoN8IU3anhq7EBxaRJsBgdghBVQm9YeDKV43hS4QvbpHIOR7dN466z78ObqSaWtqgqCNKXy5BO24wrMunLuD1dsc9LX8ZCOcX4/DZpK+ZWHJ+DKBOlBdjifMRK5wF+ZOylljp+WTiXaqeFacqDNtVbQeZn4I/4KCoLct4MmKvbnGLalCZhoEfhdCs6ktDvBGgXxZfPllHCqfz7ixZuvO1RNE3x4aadLF32LDqlrGts4/Zl69CcEt5Yu5+7V7xJRIV45ZW9PP7cFgqd8bz0fBvvvRVjkZpKPJdht9Pnmj8Z4OMdGu9t1TFmBM4APgYgV5Pm06PChkbFQJ8ilhW0LQKmQ0fGIC0OcVHEA4dJZ3OE/Brd7d0ABHWdA82H3NQc1Gnd34Ya6t9a9x3Om7n90ACapuiK9dHeFefKi85hQIrZm/oGoiapeB/vrLK5pf5kjNmnL+H0nndIlbVSMCg0r9OwB7OQ8aD3KrLHM5Dy0XnEIXaSmS94lgmRcHDYP0PDXUXIGyCdMAEoKAySiCcBiEZCDFpuj1dRUMzuY+3olNJrT+Bt4ygQoLw/DKu9/OSSczGCoRL1ROdSeXLtQyRWdmMfTrqFIwU2Argl7HBWqJQwiZzQG+4nkT1IUVGpm5Qdm9LSIrc2O0JlaTgPtDwacgnHoSTiJdHv+l1FRZSPN7cAJiX6UXrtCdjASZVLWPuXCD9ecv5ws+lkMxLrz8iO11+U6SV1bqM5Ypa8M0vKxZ3qjpmyfs3bIrYtB470iNi2ZKyc7NrXISIiZjqTp+MpK0/39idd2rbzdM75Uqan60XF66U5HROxkpLp7pJRzaYyvCpaCKeds0hgxZiSFeuC3LdN/4Xwi40PMLVoLYHj3+n7P+gBoEQ7BTaO+GOILsrZ9Bs6FdkiLNXDQH8Le6akEDtEazDFVL0Sw+e6xPe+vtLBIPQOjuLZLW6rpVI6Mt+ma75Jd+LzUWuc5Mi36q5hMulwwhHWwHbdoEVe5UJuRdP83w8uF9CJXTaI3j0CWB/oUxU53H4vDybiGy0c4b+O8FAEV1saEUMR1G3KyFGRzTBFbQIaQO8Dwqjvvvizji3ze6+lPzH6cRyIjwbiN0ebM2D5Mb1pAtbYt4Av4iFQPKyHimKoMt1IrxSNIp9OYNAg5AkggQDVqpaayEL1H+TyrJ7zr7IrAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDEyLTAzLTA2VDE1OjAzOjM4LTA4OjAwYXQSEgAAACV0RVh0ZGF0ZTptb2RpZnkAMjAxMi0wMy0wNlQxNTowMzozOC0wODowMBApqq4AAABGdEVYdHNvZnR3YXJlAEltYWdlTWFnaWNrIDYuNi42LTIgMjAxMC0xMi0wMyBRMTYgaHR0cDovL3d3dy5pbWFnZW1hZ2ljay5vcmdAFj3CAAAAGHRFWHRUaHVtYjo6RG9jdW1lbnQ6OlBhZ2VzADGn/7svAAAAF3RFWHRUaHVtYjo6SW1hZ2U6OmhlaWdodAAyN8N0v5kAAAAWdEVYdFRodW1iOjpJbWFnZTo6V2lkdGgAMzlHieHxAAAAGXRFWHRUaHVtYjo6TWltZXR5cGUAaW1hZ2UvcG5nP7JWTgAAABd0RVh0VGh1bWI6Ok1UaW1lADEzMzEwNzUwMTjyXqMqAAAAEnRFWHRUaHVtYjo6U2l6ZQAyLjZLQkL1oUptAAAALXRFWHRUaHVtYjo6VVJJAGZpbGU6Ly8vdG1wL21pbmltYWdpY2sxNzcxNy0yNi5wbmfdD3aEAAAAAElFTkSuQmCC"; env.resources["resources/auth.html"] = loadBlob("text/html", `
`); env.resources["resources/close-button.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/download-icon.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/download-manga-icon.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/exit-icon.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/eye-icon.svg"] = loadBlob("image/svg+xml", ` image/svg+xml `); env.resources["resources/favorited-icon.png"] = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAGbWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0yMFQwMDo1NjoyOS0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDYtMjBUMDE6MjM6NTktMDU6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTgtMDYtMjBUMDE6MjM6NTktMDU6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTFkYzgwNjgtMDM1Ny0xNzRlLTlmMDAtNThjYjk4NTQ4OWQ2IiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6NzdlYmJlZGEtYjQ4Yy0yYzRkLTk2MTQtYmM3NmZmN2VjYTU5IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6ZDFkMGQ2NzQtMTBkNC00MDQ1LTliZGQtNTY2ZDYxZTNlYTU0Ij4gPHBob3Rvc2hvcDpUZXh0TGF5ZXJzPiA8cmRmOkJhZz4gPHJkZjpsaSBwaG90b3Nob3A6TGF5ZXJOYW1lPSLimIUiIHBob3Rvc2hvcDpMYXllclRleHQ9IuKYhSIvPiA8L3JkZjpCYWc+IDwvcGhvdG9zaG9wOlRleHRMYXllcnM+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6ZDFkMGQ2NzQtMTBkNC00MDQ1LTliZGQtNTY2ZDYxZTNlYTU0IiBzdEV2dDp3aGVuPSIyMDE4LTA2LTIwVDAwOjU2OjI5LTA1OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDoxMWRjODA2OC0wMzU3LTE3NGUtOWYwMC01OGNiOTg1NDg5ZDYiIHN0RXZ0OndoZW49IjIwMTgtMDYtMjBUMDE6MjM6NTktMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+LpZUDAAABwxJREFUeJzlm29sG2cdxz93tnOOHedP4yZuq7QpibsSBeKWsVWZyqAQaWKTJmBVEjQoSBOVKAiYqkkVlYbGEO94AWKiiGkvOmnrRMUfsSpNUatVtEQg1JCkLWFZk5JiO2qSJk5jJznfHS98h52ssc/2+W6wr2RZ57t7nu/zfb6/3/NPFjj+FhvgA14GngMCG2/+jyIF/B74NnA394a44cGjwCLwPf5/Gg9QDfQCM8DPAcG4kSvAb4FfAG47mdkMAfgm8A/0thsCHAWedoiUE9gD/A4yAviBVxyl4wyeAlpF4Je8Pxd8WPCmCHzRaRYOYr8IeJ1m4SA8jmR8yS1T5UojudOspt2sKW5W0x4nqDgz5LXUz9HeGKc9GGdiNsTEXIiJ2ZATVOwXwONSONQ+xtEDF9i/Y4q/R3dyaqiH6YVGR1xge/YP+hN8q3uA/TsmARdd22/znYPn2OpP2E0FcECAjuY7fGzbv8jORgUe2hqlMzRtNxXAZgE8LoWe8Ih+Jaz7/mx4FI9LsZMOYLMAQX+CIw+/s6HqzPdXP3GZoANhYKsAHc13CAUWyFmM6RBoqlmko/mOnXQAGwV4sP1Zd90THrE9DGwT4MH2X0/jyMPv2B4Gtgmwuf0NCIQCC7aHgS0C5Lc/6363OwxsEaDeu0z/visFqnQmDGwRYM/WGDvrZ9m89w3YHwYVF8AlqhzcfVO/KiwAwKG2MVyiWlFeBiouQJ03SW/kqn5VSIAMncNdQ9R5kxXlZaDgatDjUvBXrdDou0+tN0m1Z61gM3IR2T5FZPuUfmXuzXAwRn/kCsPRVtP1aEBKriKx4mMuWcPymhdZcRV8T+D4W1q+BwJSisj2KXojV3nukYtIbtk0qfUQMWc4Vf8Uj9W0h1/95RBnhrsZjraytFptilVeeN0ynaFpjnUPIrnTZHqx1I8ZlF6+5E5zrHuQztA0XpMdVTAENARqpBUyJnMV0ZBSIej1lAINUKiRVtBM8izogJRcxWhsJ/GlekChVHtWHiqgEF+qZzS2k5RcZeqtggKspD2MxVt46cIz6yr6YCHbMS9deIaxeAsrJrfXCiZBAwEpxSdb3uNHT7zBgV3v6r/aERL5kLE8wNDtMN8f6Oev022mkp8BF92Hf2DmwTXFQzTRwOVbHSRlicdax3UC4IwI2dHiJ5ef4sXBXkZiu0jKUlGlmHaAAVHQaPQt0bNnhNd6X6HKZYwMpSauUqAAGpomcOTMMQb/2cXd+7WoWvEdYdoBBjQEkrLErflmzo9H2FE3T3swTqY3ihnuSkHW8ufHu/jamWNceq+Teym/6ay/EUULYEBW3MSXGrh6+yHmkgE+03ZdJ6hRmRl2NtH98I9f4seXvsCNGfPJbjMUHQIbIQoaW3z3+XTbdX769Gtsq72n37EqQWpkGq6xuOLj6K+/wcWJTuaSgZIsvxFlnwypmsDscoBzN/cxOd/E6/0/Y2/Tv8n0mBUHTxnLT8430ff6dxmLtxSd6PLBMq8mZYmxeAvnx7ssLjpTzvnxLssbny3dIvirVjn4EbNrf7MwDk7GqKu2folsqQDtjXH9zA+sFUAgHIxVZKfIMgFcosrjbTesLlZH5TZMLWNa503SFzE2Pq2eC1Ruw9QyASpj/1xUZsPUEgFKs78KpDG/vK5MGFgiQHH2N6azRsON5XWh+VhlwsASAczb32i8xsWJTo7/4StcnOhc93t+WB8GZU/VzNs/28CTA328MfwY8UQ9vxl7hP7IFV5+4k39mXwrSwHQ6AmPcPlWh6ld30Io2wGF7Z/t3dnlAIdPP8+poR6m5ptIyhJT802cGurh8OnnmV0OkN8N1odB2QLkt3+2MWdHH+XJV09w7uY+ZpezC5nctcSTr57g7Oij5BfB2jAoS4D89lcwFjInB/p44e1nuRbdvelcPilLXIvu5oW3n+XkQN/7ysjC2tGgLAECUorPhUf1K6P3N7d8oZiVFZeJkMhQ/vzea2zx3S+Hfk5pJaLOm9RPfcFIUIUsXwj5Q8IYOgV2Ndwl6Cs/D5QlgNctMznfRKbxxVn+AUjr3yqg5obEiXNfzrmVGSkm5kL4qtbKoQ+UsSUGUO1ZQ9FEtviWcQkqF979OC8O9nJ29ACxRAOKWpS+xsP/3VhUNZHFFT/XZ1q4MrUXl6gS9Ce4vdDE6b99iuFoK/PJmlLp65WVsSUWkFI0BxZprllE0URmluqYWaqzfNMCMm7bVnuPbbX38LplYokGYkv1LKT8ZZVblgCioCEIGqKgoWoCmiZYsk9nZ31lzQRVTQBNsO2grBL1iXzwDvrshCICf3aahYOYFMn8o/LDiq+LQBQ44zQTB3AL+JMx9vYDzvxjwRksAx+F7ORDA1qBSw4RshMTQCOwBuunwipwCOhwgFSloQA3gMeBMLBq3PgPsH6q+iD6RQEAAAAASUVORK5CYII="; env.resources["resources/folder.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/followed-users-eye.svg"] = loadBlob("image/svg+xml", ` image/svg+xml `); env.resources["resources/fullscreen.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/heart-icon.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/icon-twitter.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/index.html"] = loadBlob("text/html", ` VView `); env.resources["resources/last-page.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/last-viewed-image-marker.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/like-button.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/main.css"] = loadBlob("text/scss", `* { box-sizing: border-box; overscroll-behavior: contain; touch-action: pan-x pan-y; -webkit-tap-highlight-color: rgba(255, 255, 255, 0); -webkit-touch-callout: none; } html { overflow: hidden; height: 100%; width: 100%; height: 100%; background-color: var(--main-background-color); background-image: var(--background-noise); touch-action: none !important; position: relative; --fixed-safe-area-inset-bottom: env(safe-area-inset-bottom); --fullscreen-left: 0; --fullscreen-top: 0; --fullscreen-right: 0; --fullscreen-bottom: 0; --device-edge-radius: 0; } html.ios { height: calc(100% + env(safe-area-inset-top)); } html.ios { --fixed-safe-area-inset-bottom: calc(env(safe-area-inset-bottom) * 0.5); } html[data-display-mode=notch][data-orientation="0"] { --fullscreen-top: env(safe-area-inset-top); } html[data-display-mode=notch][data-orientation="90"] { --fullscreen-left: env(safe-area-inset-left); } html[data-display-mode=notch][data-orientation="-90"] { --fullscreen-right: env(safe-area-inset-right); } html[data-display-mode=notch][data-orientation="180"] { --fullscreen-bottom: env(safe-area-inset-bottom); } html[data-display-mode=safe] { --fullscreen-top: env(safe-area-inset-top); } html[data-display-mode=notch] { --device-edge-radius: 14vmin; } html:not(.macos) .search-results::-webkit-scrollbar { width: 10px; background-color: rgb(30, 30, 30); } html:not(.macos) ::-webkit-scrollbar { width: 8px; background-color: #888; } html:not(.macos) ::-webkit-scrollbar:hover { background-color: #888; } html:not(.macos) ::-webkit-scrollbar-thumb { transition: background-color 0.25s; background-color: rgb(0, 0, 0); border: 1px solid rgba(0, 0, 0, 0); background-clip: padding-box; } html:not(.macos) ::-webkit-scrollbar-thumb:hover { background-color: #008; } html:not(.macos) ::-webkit-resizer { background-color: #222; } .ppixiv-icon { font-family: "ppixiv"; font-style: normal; font-weight: normal; font-variant: normal; text-transform: none; line-height: 1; letter-spacing: 0; font-feature-settings: "liga"; -webkit-font-feature-settings: "liga"; font-variant-ligatures: discretionary-ligatures; -webkit-font-variant-ligatures: discretionary-ligatures; -webkit-font-smoothing: antialiased; } @font-face { font-family: "Material Icons"; font-style: normal; font-weight: 400; src: url("https:/\x2ffonts.gstatic.com/s/materialicons/v129/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2") format("woff2"); } .material-icons { font-family: "Material Icons"; font-weight: normal; font-style: normal; line-height: 1; letter-spacing: normal; text-transform: none; display: inline-block; white-space: nowrap; word-wrap: normal; direction: ltr; -webkit-font-feature-settings: "liga"; -webkit-font-smoothing: antialiased; } body { font-family: "Helvetica Neue", arial, sans-serif; margin: 0; color: #fff; height: 100%; } a { text-decoration: none; color: inherit; } html.firefox [inert] { pointer-events: none !important; } html.firefox [inert] * { pointer-events: none !important; } html.mobile * { user-select: none; -webkit-user-select: none; } html.mobile .popup:after { display: none !important; } html.mobile > body { font-size: 150%; } /* Theme colors: */ html { --icon-size: 1.7; --icon-row-gap: 0.1em; --main-background-color: #222; --background-noise: var(--dark-noise); --button-color: #888; --button-highlight-color: #eee; --button-disabled-color: #444; --input-outline: #444; --input-outline-focused: #888; /* Colors for major UI boxes */ --ui-bg-color: rgba(0,0,0,0.88); --ui-fg-color: #fff; --ui-border-color: #000; --ui-bg-section-color: #555; /* color for sections within UI, like the description box */ /* Color for frames like popup menus */ --frame-bg-color: rgba(0,0,0,0.9); --frame-fg-color: #fff; --frame-border-color: #444; --dropdown-menu-hover-color: #444; /* Box links used for selection in the search UI: */ --box-link-fg-color: var(--frame-fg-color); --box-link-bg-color: #000; --box-link-disabled-color: #888; --box-link-hover-color: #443; --box-link-selected-color: #008; --box-link-selected-hover-color: #338; /* Color for the minor text style, eg. the bookmark and like counts. * This is smaller text, with a text border applied to make it readable. */ --minor-text-fg-color: #aaa; --minor-text-shadow-color: #000; --title-fg-color: #fff; /* title strip in image-ui */ --title-bg-color: #444; --like-button-color: #888; --like-button-liked-color: #ccc; --like-button-hover-color: #fff; } html.mobile { --icon-size: 3; --icon-row-gap: 0.5em; --main-background-color: #000; --background-noise: none; } html.mobile [data-hidden-on~=mobile] { display: none !important; } html.ios [data-hidden-on~=ios] { display: none !important; } html.android [data-hidden-on~=android] { display: none !important; } [hidden] { display: none !important; } vv-container { display: contents; } input, textarea, [contenteditable] { padding: 1px 2px; font-family: unset; font-size: unset; background-color: var(--frame-bg-color); color: var(--frame-fg-color); border: none; outline: 1px solid var(--input-outline); } input:focus-within, textarea:focus-within, [contenteditable]:focus-within { outline: 1px solid var(--input-outline-focused); } /* Pixiv sometimes displays a random Recaptcha icon in the corner. It's hard to prevent this since it * sometimes loads before we have a chance to stop it. Try to hide it. */ .grecaptcha-badge { display: none !important; } .viewer-images { touch-action: none; } .viewer-images .image-box { position: relative; transform-origin: 0 0; right: auto; bottom: auto; } .viewer-images .image-box > .crop-box { position: relative; width: 100%; height: 100%; } .viewer-images .image-box.cropping { overflow: hidden; } html.ios .viewer-images .image-box.cropping { will-change: transform; } html:not(.ios) .viewer-images > .image-box img { will-change: transform; } .viewer-images .displayed-image { position: absolute; width: 100%; height: 100%; } .viewer-images .displayed-image:not(.main-image) { pointer-events: none; } .viewer-images .inpaint-image, .viewer-images .low-res-preview { pointer-events: none; } .viewer-video > .video-container { width: 100%; height: 100%; touch-action: none; } .viewer-video .top-seek-bar { position: absolute; top: 0; left: 0; width: 100%; height: 10px; pointer-events: none; } .viewer-video .top-seek-bar .seek-bar { height: 4px; } .viewer-video .top-seek-bar .seek-bar > .seek-parts { transition: transform 0.25s; transform: scale(100%, 0%); transform-origin: top; } .viewer-video .top-seek-bar .seek-bar.dragging > .seek-parts { transform: scale(100%, 100%) !important; } .checkbox { font-size: 150%; } .video-ui { position: absolute; bottom: 0; left: 0px; width: 100%; user-select: none; -webkit-user-select: none; touch-action: none; opacity: 0; } html.mobile .video-ui { bottom: 0; } html:not(.mobile) .video-ui { transition: transform 0.25s, opacity 0.25s; } .mouse-hidden-box.cursor-active html:not(.mobile) .video-ui, html:not(.mobile) .video-ui.dragging, html:not(.mobile) .video-ui:hover, html:not(.mobile) .video-ui.show-ui { opacity: 1; } .mouse-hidden-box.cursor-active html:not(.mobile) .video-ui .seek-bar[data-position=top] > .seek-parts, html:not(.mobile) .video-ui.dragging .seek-bar[data-position=top] > .seek-parts, html:not(.mobile) .video-ui:hover .seek-bar[data-position=top] > .seek-parts, html:not(.mobile) .video-ui.show-ui .seek-bar[data-position=top] > .seek-parts { transform: scale(100%, 50%); } html.mobile .video-ui { opacity: var(--menu-bar-pos); } html.mobile .video-ui .seek-bar[data-position=top] > .seek-parts { transform: scale(100%, calc(50% * var(--menu-bar-pos))); } html.mobile .video-ui:not(.show-ui) { pointer-events: none; } .video-ui .seek-bar { height: 12px; } .video-ui .seek-bar[data-position=top] { padding-top: 25px; } html.mobile .video-ui .seek-bar { padding-top: 2em; } .video-ui .seek-bar[data-position=top] > .seek-parts { transition: transform 0.25s; transform: scale(100%, 0%); transform-origin: bottom; } .video-ui .seek-bar[data-position=bottom] { height: 4px; } .video-ui .seek-bar[data-position=bottom] > .seek-parts > [data-seek-part=empty] { background-color: rgba(0, 0, 0, 0.5); } .video-ui .seek-bar.dragging > .seek-parts { transform: scale(100%, 100%) !important; } .video-ui > .video-ui-strip { width: 100%; padding: 0.25em 1em; display: flex; flex-direction: row; color: #ffffff; align-items: center; gap: 10px; background-color: rgba(0, 0, 0, 0.5); cursor: default; padding-top: 4px; } .video-ui > .video-ui-strip .button { cursor: pointer; } .video-ui > .video-ui-strip .font-icon { font-size: 36px; } .video-ui > .video-ui-strip > .time { font-family: Roboto, Arial, Helvetica, sans-serif; font-size: 1.2em; } .video-ui > .video-ui-strip .volume-slider { display: flex; flex-direction: row; align-items: center; margin-right: -10px; padding: 0.5rem 0; cursor: pointer; } .video-ui > .video-ui-strip .volume-slider > .volume-line { height: 0.25rem; width: 100px; } .seek-bar { width: 100%; box-sizing: content-box; cursor: pointer; position: relative; } .seek-bar > .seek-parts { width: 100%; height: 100%; } .seek-bar > .seek-parts > [data-seek-part] { height: 100%; position: absolute; left: 0; top: 0; } .seek-bar > .seek-parts > [data-seek-part=fill] { background-color: #F00; } .seek-bar > .seek-parts > [data-seek-part=loaded] { background-color: #A00; } .seek-bar > .seek-parts > [data-seek-part=empty] { background-color: rgba(0, 0, 0, 0.25); width: 100%; } .title-font { font-weight: 700; font-size: 20px; font-family: system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Droid Sans, Helvetica Neue, Hiragino Kaku Gothic ProN, Meiryo, sans-serif; } .small-font { font-size: 0.8em; } .hover-message { display: flex; justify-content: center; align-items: center; user-select: none; -webkit-user-select: none; margin: 0; color: var(--frame-fg-color); font-size: 1em; pointer-events: none; font-size: 1.5rem; position: fixed; left: max(env(safe-area-inset-left), 1rem); right: max(env(safe-area-inset-right), 1rem); --extra-distance: 0px; bottom: calc(2vh + env(safe-area-inset-bottom) + var(--extra-distance)); z-index: 100000; /* over everything */ transition: opacity 0.25s, bottom 0.25s; opacity: 0; } .hover-message > .message { background-color: rgba(0, 0, 0, 0.5); border-radius: 5px; overflow: hidden; text-align: center; max-width: 40em; max-height: 50vh; padding: 0.25em 0.5em; } html.mobile.illust-menu-visible .hover-message { --extra-distance: 3em; } .hover-message.show { opacity: 1; } .image-ui .disabled { display: none; } .image-ui .hover-sphere { width: 500px; height: 500px; /* Clamp the sphere to a percentage of the viewport width, so it gets smaller for * small windows. */ max-width: 30vw; max-height: 30vw; position: absolute; top: 0; left: 0; pointer-events: none; } .image-ui .hover-sphere circle { pointer-events: auto; /* reenable pointer events that are disabled on .ui */ } .image-ui .hover-sphere > svg { width: 100%; height: 100%; transform: translate(-50%, -50%); } .image-ui .ui-box { position: absolute; top: 1vmin; left: 1vmin; min-width: min(450px, 90vw); max-height: min(500px, 100vh - 2vmin); width: 30%; color: var(--ui-fg-color); border: solid 2px var(--ui-border-color); background-color: var(--ui-bg-color); border-radius: 8px; transition: transform 0.25s, opacity 0.25s; overflow-x: hidden; overflow-y: auto; overflow-y: overlay; opacity: 1; transform: translate(0, 0); } body:not(.force-ui) .image-ui .ui-box.ui-hidden { opacity: 0; transform: translate(-50px, 0); pointer-events: none; user-select: none; } .image-ui .ui-box .avatar-popup { position: absolute; width: 4em; top: 1em; right: 1em; } .image-ui .ui-box .ui-title-box { display: flex; flex-direction: row; margin-right: 4em; } .image-ui .ui-box .post-info > * { display: inline-block; background-color: var(--box-link-bg-color); color: var(--box-link-fg-color); padding: 2px 10px; font-size: 0.8em; font-weight: bold; } .image-ui .ui-box .description { border: solid 1px var(--ui-border-color); white-space: pre-wrap; padding: 0.35em; background-color: var(--ui-bg-section-color); overflow-wrap: break-word; } .image-ui .ui-box .author { vertical-align: top; } .image-ui .ui-box .button.button-bookmark .count, .image-ui .ui-box .button.button-like .count { top: calc(100% - 11px); pointer-events: none; } .image-ui .ui-box .manga-page-bar { position: sticky; bottom: 0px; left: 0px; z-index: 1; pointer-events: none; background-color: #F00; width: 100%; height: 2px; } .title-with-button-row-container .title-with-button-row { display: flex; flex-direction: row; align-items: start; } @media (hover: hover) { .title-with-button-row-container { height: 2em; overflow: hidden; } .title-with-button-row-container:hover { overflow: visible; } .title-with-button-row-container:hover .title-with-button-row { background-color: var(--ui-bg-color); position: relative; z-index: 1; } } .button-row { display: flex; flex-wrap: wrap; flex-direction: row; align-items: center; gap: var(--icon-row-gap); } .button-row .button.enabled { cursor: pointer; } .icon-button { color: var(--button-color); cursor: pointer; user-select: none; -webkit-user-select: none; display: block; width: calc(1em * var(--icon-size)); height: calc(1em * var(--icon-size)); flex-shrink: 0; } .icon-button .material-icons, .icon-button .ppixiv-icon { display: block; font-size: calc(100% * var(--icon-size)); } .icon-button svg { display: block; width: 100%; height: 100%; } @media (hover: hover) { .icon-button:hover { color: var(--button-highlight-color); } } .icon-button.highlighted { color: var(--button-highlight-color); } .popup-visible > .icon-button { color: var(--button-highlight-color); } .icon-button.disabled { color: var(--button-disabled-color); } @media (hover: hover) { .disable-ui-button:hover { color: #0096FA; } } .avatar-widget { display: block; position: relative; } .avatar-widget .avatar { transition: filter 0.25s; display: block; filter: contrast(1); border-radius: 5px; object-fit: cover; aspect-ratio: 1; width: 100%; height: 100%; } @media (hover: hover) { .avatar-widget .avatar:hover { filter: contrast(1.3); } } .avatar-widget .follow-icon { position: absolute; bottom: 5%; right: 5%; width: 50%; /* half the size of the container */ max-width: 2em; /* limit the size for larger avatar displays */ pointer-events: none; } .avatar-widget .follow-icon > svg { display: block; width: 100%; height: auto; transition: opacity 0.25s; /* Move the icon down, so the bottom of the eye is along the bottom of the * container and the lock (if visible) overlaps. */ margin-bottom: -20%; } .avatar-widget .follow-icon > svg .middle { transition: transform 0.1s ease-in-out; transform: translate(0px, -2px); } .follow-widget { max-width: min(90vw, 40em); } .follow-widget .separator { height: 2px; width: 100%; background-color: #fff; margin: 2px 0; } .follow-widget .material-icons { margin-right: 8px; } .title-block { display: inline-block; padding: 0 10px; color: var(--title-fg-color); background-color: var(--title-bg-color); margin-right: 1em; border-radius: 8px 0; } @media (hover: hover) { .title-block.popup:hover:after { top: 40px; bottom: auto; } } /* When .dot is set, show images with nearest neighbor filtering. */ body.dot img.filtering, body.dot canvas.filtering { image-rendering: crisp-edges; image-rendering: pixelated; } /* Override obnoxious colors in descriptions. Why would you allow this? */ .description * { color: var(--ui-fg-color); } .popup { position: relative; } @media (hover: hover) { .popup:hover:after { pointer-events: none; background: #111; border-radius: 0.5em; top: -2em; color: #fff; content: attr(data-popup); display: block; padding: 0.3em 1em; position: absolute; text-shadow: 0 1px 0 #000; white-space: nowrap; z-index: 98; } .popup[data-popup-side=left]:hover:after { right: 0em; } .popup[data-popup-side=right]:hover:after, .popup:not([data-popup-side]):hover:after { left: 0em; } .popup.popup-bottom:hover:after { top: auto; bottom: -2em; } } body:not(.premium) .premium-only { display: none; } body:not(.native) .native-only { display: none; } body:not(.pixiv) .pixiv-only { display: none; } body.hide-r18 .r18 { display: none; } body.hide-r18g .r18g { display: none; } .dropdown-box { position: absolute; overflow-x: hidden; overflow-y: auto; border: 1px solid var(--frame-border-color); background-color: var(--frame-bg-color); border-radius: 5px; } .popup-menu-box { overflow-y: auto; min-width: 10em; padding: 0.25em 0.25em; } .popup-menu-box.hover-menu-box { visibility: hidden; } .popup-visible .popup-menu-box.hover-menu-box { visibility: inherit; } /* This is an invisible block underneath the hover zone to keep the hover UI visible. */ .hover-area { position: absolute; top: -50%; left: -33%; width: 150%; height: 200%; z-index: -1; } .screen-search-container { --nav-bar-reserved-height: 1em; --title-height: 0px; } .screen-search-container .search-ui-mobile { position: fixed; top: 0; translate: 0 calc(var(--title-height) * -1); transition: translate 0.25s, opacity 0.25s; opacity: 0; font-size: 1.2rem; width: 100%; z-index: 1; padding: 0.5em; padding-top: max(0.5em, env(safe-area-inset-top)); backdrop-filter: blur(15px) contrast(60%) brightness(0.5); -webkit-backdrop-filter: blur(15px) contrast(60%) brightness(0.5); } .screen-search-container .search-ui-mobile.shown { translate: 0px 0px; opacity: 1; } .screen-search-container .search-ui-mobile .search-title { text-align: center; } .screen-search-container .search-ui-mobile .avatar-widget { width: 100px; } .screen-search-container .search-ui-mobile .data-source-ui .box, .screen-search-container .search-ui-mobile .data-source-ui -button-row, .screen-search-container .search-ui-mobile .data-source-ui .box-button-row-group { justify-content: center; } .screen-search-container .search-title .word { padding: 0px 5px; vertical-align: middle; line-height: 1.5em; } .screen-search-container .search-title .word.paren { font-weight: 400; } .screen-search-container .search-title .word:first-child { padding-left: 0px; /* remove left padding from the first item */ } .screen-search-container .search-title .word.or { font-size: 60%; padding: 0; color: #bbb; } .screen-search-container .mobile-navigation-bar { backdrop-filter: blur(15px) contrast(60%) brightness(0.5); -webkit-backdrop-filter: blur(15px) contrast(60%) brightness(0.5); position: fixed; left: 0; width: 100%; z-index: 1; display: flex; flex-direction: column; --icon-size: 2; --button-color: #5a91f7; --button-disabled-color: #777; font-size: 1.2rem; transition: bottom 0.25s; bottom: calc(var(--nav-bar-height) * -1); } html.mobile .screen-search-container .mobile-navigation-bar { padding-top: 0px; padding-bottom: env(safe-area-inset-bottom); } html.mobile:not([data-has-bottom-inset]) .screen-search-container .mobile-navigation-bar { padding-top: 0.75rem; padding-bottom: 0.75rem; } html.mobile[data-has-bottom-inset] .screen-search-container .mobile-navigation-bar { padding-top: 0.5rem; padding-bottom: calc(env(safe-area-inset-bottom) * 0.5); } .screen-search-container .mobile-navigation-bar.shown { bottom: -1px; } .screen-search-container .mobile-navigation-bar:not(.shown) { pointer-events: none; } .screen-search-container .mobile-navigation-bar .header-contents { display: flex; flex-direction: row; justify-content: space-around; } .screen-search-container .search-results { margin-left: var(--navigation-box-reserved-width); height: 100%; padding-top: calc(env(safe-area-inset-top) + var(--title-height)); padding-bottom: calc(env(safe-area-inset-bottom)); } .screen-search-container .search-desktop-ui { position: sticky; width: 100%; display: flex; flex-direction: row; justify-content: center; padding-top: max(1em, env(safe-area-inset-top)); padding-bottom: 1em; z-index: 1; transition: top ease-out 0.2s; } html:not(.mobile) .screen-search-container .search-desktop-ui { pointer-events: none; } html:not(.mobile) .screen-search-container .search-desktop-ui .thumbnail-ui-box { pointer-events: auto; } .screen-search-container .search-desktop-ui:not(.ui-on-hover) { top: 0; } .screen-search-container .search-desktop-ui.ui-on-hover { top: 0; } html:not(.mobile) .screen-search-container .search-desktop-ui.ui-on-hover { pointer-events: auto; } .screen-search-container .search-desktop-ui.ui-on-hover:not(.hover):not(.force-open) { top: calc(-1 * var(--ui-box-height) + 40px); } .screen-search-container .thumbnail-ui-box { width: 50%; height: fit-content; min-width: min(800px, 100%); padding: 0 15px; background-color: var(--ui-bg-color); color: var(--ui-fg-color); border-radius: 4px; box-shadow: 0 0 15px 10px #000; } .screen-search-container .thumbnail-ui-box .avatar-container > .avatar-widget { float: right; width: 100px; margin-left: 25px; } .screen-search-container .thumbnail-ui-box .image-for-suggestions { float: right; margin-left: 25px; } .screen-search-container .thumbnail-ui-box .image-for-suggestions > img { display: block; height: 100px; width: 100px; object-fit: cover; object-position: 50% 0; border-radius: 5px; /* matches the avatar display */ } html[data-whats-new-updated] .screen-search-container .thumbnail-ui-box .settings-menu-box { --button-color: #cc0; } .screen-search-container .following-tag { text-decoration: none; } .search-results { overflow-y: scroll; overflow-x: clip; scrollbar-gutter: stable both-edges; } html.has-overlay-scrollbars:not(.mobile) .search-view { margin: 0 1em; } .search-view .flash a { animation-name: flash-thumbnail; animation-duration: 300ms; animation-timing-function: ease-out; animation-iteration-count: 1; } @keyframes flash-thumbnail { 0% { filter: brightness(200%); } } .search-view > .no-results { display: flex; justify-content: center; align-items: center; user-select: none; -webkit-user-select: none; margin: 0; color: var(--frame-fg-color); font-size: 1em; pointer-events: none; font-size: 1.5rem; margin-top: 2em; } .search-view > .no-results > .message { background-color: rgba(0, 0, 0, 0.5); border-radius: 5px; overflow: hidden; text-align: center; max-width: 40em; max-height: 50vh; padding: 0.25em 0.5em; } .search-view > .no-results > .message { padding: 0.5em 1em; } .search-view .last-viewed-image-marker { position: absolute; left: 0; top: 0; pointer-events: none; height: auto; width: calc(var(--thumb-width) / 4); } .search-view .thumbnail-box:not(.flash) .last-viewed-image-marker { display: none; } .search-view .thumbnail-box.expanded-thumb a.thumbnail-link { border-bottom: 10px solid #a0a; border-bottom-width: 5px; border-bottom-style: solid; } html:not(.mobile) .search-view .thumbnail-box.expanded-thumb a.thumbnail-link.first-page { border-bottom-left-radius: 30px; } html:not(.mobile) .search-view .thumbnail-box.expanded-thumb a.thumbnail-link.last-page { border-bottom-right-radius: 30px; } .search-view .thumbnails { user-select: none; -webkit-user-select: none; text-align: center; row-gap: var(--thumb-padding); width: var(--container-width); display: flex; flex-direction: column; margin: 0 auto; /* Add a stroke around the heart on thumbnails for visibility. Don't * change the black lock. */ } .search-view .thumbnails .row { width: 100%; display: flex; flex-direction: row; column-gap: var(--thumb-padding); align-items: flex-end; justify-content: center; } .search-view .thumbnails .button-bookmark svg > .heart { stroke: #000; stroke-width: 0.5px; } html:not(.ios) .search-view .row { content-visibility: auto; contain-intrinsic-height: var(--row-height); contain: strict; } .search-view .thumbnail-box { position: relative; width: var(--thumb-width); height: var(--thumb-height); flex-shrink: 0; /* Hide pending images (they haven't been set up yet). */ } .search-view .thumbnail-box .thumb { object-fit: cover; /* Show the top-center of the thunbnail. This generally makes more sense * than cropping the center. */ object-position: 50% 0%; width: 100%; height: 100%; } .search-view .thumbnail-box[data-pending] { visibility: hidden; } .search-view .thumbnail-box a.thumbnail-link { display: block; width: 100%; height: 100%; } html:not(.mobile) .search-view .thumbnail-box a.thumbnail-link { border-radius: 4px; overflow: hidden; } @media (hover: hover) { .search-view .thumbnail-box { --zoom-thumb: 1; --pan-thumb: paused; } body:not(.disable-thumbnail-zooming) .search-view .thumbnail-box:not(:hover) { --zoom-thumb: 1.25; } .search-view .thumbnail-box .thumb { transition: transform 0.5s; transform: scale(var(--zoom-thumb)); } body:not(.disable-thumbnail-panning) .search-view .thumbnail-box:hover { --pan-thumb: running; } } .search-view .thumbnail-box.muted { overflow: hidden; } .search-view .thumbnail-box.muted .muted-text { position: absolute; left: 0; top: 0; width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; pointer-events: none; color: #fff; } .search-view .thumbnail-box.muted .muted-text .muted-icon { opacity: 0.8; display: block; font-size: min(var(--thumb-height) * 0.25, 3rem); } .search-view .thumbnail-box.muted .muted-text .muted-label { font-size: 1rem; } .search-view .thumbnail-box.muted .thumb { filter: blur(5px) grayscale(0.2) brightness(0.3); transform: scale(1.25, 1.25); } body:not(.disable-thumbnail-zooming) .search-view .thumbnail-box.muted .thumb:hover { transform: scale(1, 1); } .search-view .thumbnail-box:not(.muted) .muted-text { display: none; } @media (hover: hover) { .search-view .thumbnail-box.expanded-manga-post:not(:hover):not(.first-manga-page) .manga-info-box { display: none; } } .search-view .thumbnail-box .bottom-row { position: absolute; display: flex; align-items: end; justify-content: center; gap: 4px; pointer-events: none; width: 100%; max-height: 75%; bottom: 3px; padding: 0 4px; overflow: hidden; } html.mobile .search-view .thumbnail-box .bottom-row { font-size: 1rem; } .search-view .thumbnail-box .bottom-row .bottom-left-icon, .search-view .thumbnail-box .bottom-row .bottom-right-icon { height: 32px; width: 100px; flex-shrink: 100000; display: flex; align-items: center; gap: 0.25em; } .search-view .thumbnail-box .bottom-row .bottom-right-icon { display: flex; justify-content: end; align-self: end; } .search-view .thumbnail-box .bottom-row .thumbnail-label { display: flex; align-items: center; align-self: start; gap: 0.5em; flex-shrink: 1; color: var(--frame-fg-color); background-color: rgba(0, 0, 0, 0.6); padding: 4px 8px; overflow: hidden; border-radius: 6px; } .search-view .thumbnail-box .bottom-row .thumbnail-label .thumbnail-ellipsis-box { text-overflow: ellipsis; overflow: hidden; } .search-view .thumbnail-box .bottom-row .thumbnail-label .thumbnail-ellipsis-box > .label { /* Specify a line-height explicitly, so vertical centering is reasonably consistent for * both EN and JP text. */ line-height: 19px; } .search-view .thumbnail-box .bottom-row .thumbnail-label .ugoira-icon { color: #fff; } .search-view .thumbnail-box .bottom-row .heart { width: 32px; height: 32px; } .search-view .thumbnail-box .bottom-row .manga-info-box { display: inline-flex; align-items: center; justify-content: center; vertical-align: middle; padding: 0.25em 0.5em; background-color: rgba(0, 0, 0, 0.6); border-radius: 6px; white-space: nowrap; cursor: pointer; border-radius: 6px; overflow: hidden; } html:not(.mobile) .search-view .thumbnail-box .bottom-row .manga-info-box { pointer-events: auto; } .search-view .thumbnail-box .bottom-row .manga-info-box:hover { background-color: rgba(0, 20, 120, 0.8); } .search-view .thumbnail-box .bottom-row .manga-info-box:hover .regular { display: none; } .search-view .thumbnail-box .bottom-row .manga-info-box:not(:hover) .hover { display: none; } .search-view .thumbnail-box .bottom-row .manga-info-box .page-count { vertical-align: middle; padding-left: 0.15em; } html:not(.ios) .search-view .thumbnail-box .bottom-row .manga-info-box .page-count { margin-bottom: -0.15em; } html.mobile .search-view .thumbnail-box .bottom-row .manga-info-box.show-expanded .page-icon { display: none; } html.mobile .search-view .thumbnail-box .bottom-row .manga-info-box.show-expanded .page-count { padding-left: 0; margin-bottom: 0; } .search-view .thumbnail-box .bottom-row .ai-image { height: 100%; object-fit: contain; padding: 4px; image-rendering: pixelated; } @media (hover: hover) { .search-view .thumbnail-box:hover .heart > svg, .search-view .thumbnail-box:hover .ugoira-icon, .search-view .thumbnail-box:hover .ai-image { opacity: 0.5; } } .search-view [data-type=order-shuffle] .icon { font-size: 24px; } .search-view .artist-header { display: flex; flex-direction: column; align-items: center; width: var(--container-width); padding-bottom: var(--thumb-padding); margin: 0 auto; user-select: none; -webkit-user-select: none; } .search-view .artist-header .shape { width: 100%; height: min(20vh, var(--row-height)); overflow: hidden; background-color: #333; } html:not(.mobile) .search-view .artist-header .shape { border-radius: 50px 50px 0 0; } .search-view .artist-header .shape img.bg { width: 100%; height: 100%; object-fit: cover; object-position: 50% 100%; } .search-view .artist-header .shape img.bg.loaded { object-position: 50% 20%; } .search-view .artist-header .shape img.bg.loaded:not(.animated) { object-position: 50% 40%; } .search-view .artist-header .shape img.bg.animated { transition: object-position ease 5s; } .box-link-row { display: flex; flex-direction: row; align-items: center; gap: 0.5em; } .box-link-row > .box-link { padding-left: 0.5em; padding-right: 0.5em; } .box-button-row { display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; row-gap: 0.5em; } html.mobile .box-button-row { justify-content: center; } .box-button-row.group { column-gap: 1em; } .box-button-row > .box-link { margin: 0 0.25em; padding: 0 0.5em; } .box-button-row > .box-link > * { padding: 0.25em 0; } .vertical-list { --box-link-bg-color: rgba(0,0,0,0); user-select: none; -webkit-user-select: none; } .vertical-list > .box-link { padding-left: 0.5em; padding-right: 0.5em; } .vertical-list > .box-link { display: flex; flex-direction: row; align-items: center; margin-top: 0; margin-bottom: 0; } .box-link { display: inline-flex; cursor: pointer; text-decoration: none; margin: 0; padding: 0 0.75em; align-content: center; align-items: center; height: 2em; line-height: 1.5em; border-radius: 2px; color: var(--box-link-fg-color); user-select: none; -webkit-user-select: none; background-color: var(--box-link-bg-color); } .box-link .label-box { flex: 1; display: flex; align-items: center; } .box-link .label { vertical-align: middle; flex: 1; } .box-link:not(.allow-wrap) { white-space: nowrap; } .box-link.selected { background-color: var(--box-link-selected-color); } @media (hover: hover) { .box-link:not(.disabled):hover { background-color: var(--box-link-hover-color); } .box-link:not(.disabled):hover.selected { background-color: var(--box-link-selected-hover-color); } } .box-link:not(.disabled):active { background-color: var(--box-link-hover-color); } .box-link:not(.disabled):active.selected { background-color: var(--box-link-selected-hover-color); } .box-link.disabled { color: var(--box-link-disabled-color); cursor: auto; pointer-events: none; } .box-link.tag { /* Some tags are way too long, since translations don't put any sanity limit on length. * Cut these off so they don't break the layout. */ max-width: 100%; text-overflow: ellipsis; overflow: hidden; } .box-link .icon { display: inline-block; font-size: inherit; vertical-align: middle; } .box-link .icon.with-text { margin-right: 0.25em; width: 1ch; } .box-link .icon:not(.with-text) { font-size: 150%; } .search-box { display: flex; } .tag-search-box { display: flex; flex-wrap: wrap; align-items: center; position: relative; gap: 0.5em; } .tag-search-box > .search-history { position: absolute; left: 0; bottom: 0; } .input-field-container { flex: 1; padding: 6px 10px; outline: 1px solid var(--input-outline); display: inline-flex; gap: 0.5em; align-items: center; } .input-field-container:focus-within { outline: 1px solid var(--input-outline-focused); } .input-field-container > input { outline: none; font-size: 1.2em; vertical-align: middle; flex: 1; min-width: 0; } .input-field-container > .right-side-button { display: flex; font-size: 150%; vertical-align: middle; cursor: pointer; user-select: none; -webkit-user-select: none; } /* Search box in the menu: */ .navigation-search-box .search-submit-button { vertical-align: middle; margin-left: -30px; /* overlap the search box */ } .navigation-search-box input.search-tags { width: 100%; padding-right: 30px; /* extra space for the submit button */ } .viewer-error .muted-image { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; filter: blur(20px); opacity: 0.75; } .viewer-error .error-text-container { position: absolute; width: 100%; top: 50%; left: 0; text-align: center; font-size: 30px; color: #000; text-shadow: 0px 1px 1px #fff, 0px -1px 1px #fff, 1px 0px 1px #fff, -1px 0px 1px #fff; } .data-source-tag-list { max-height: 50vh; min-width: min(20em, 100vw); max-width: 100vw; overflow-x: hidden; overflow-y: auto; white-space: nowrap; } .data-source-tag-list .recent { --box-link-bg-color: #550; --box-link-hover-color: #660; } .data-source-tag-list .tag-entry:hover:after { left: auto; right: 0px; } .input-dropdown { user-select: none; -webkit-user-select: none; resize: horizontal; width: var(--width); max-width: min(800px, 100vw); } .input-dropdown:not(.editing) .edit-button, .input-dropdown:not(.editing) .editing-only { display: none !important; } .input-dropdown .edit-button { align-items: center; justify-content: center; border: 1px solid #bbb; height: 1.5em; width: 1.5em; cursor: pointer; display: flex; } .input-dropdown .edit-button.selected { color: #000; background-color: #ccc; } @media (hover: hover) { .input-dropdown .edit-button:hover { border-color: #fff; } } .input-dropdown.loading { pointer-events: none; opacity: 0; } .input-dropdown .input-dropdown-list { margin: 1px; display: flex; flex-direction: column; white-space: normal; margin-right: 3px; } .input-dropdown .input-dropdown-list .tag-section:not(.user-section) .user-section-edit-button { display: none !important; } .input-dropdown .input-dropdown-list .tag-section.autocomplete .delete-entry { display: none !important; } .input-dropdown .input-dropdown-list .tag-section { position: sticky; top: 0; width: 100%; display: flex; gap: 0.5em; align-items: center; background-color: #001a44; padding: 0.5em 1em; margin-bottom: 5px; cursor: pointer; line-height: 1.5em; } .input-dropdown .input-dropdown-list .tag-section .label, .input-dropdown .input-dropdown-list .tag-section .label-edit { flex: 1; } .input-dropdown .input-dropdown-list.editing .tag-section { padding: 0.5em; } .input-dropdown .input-dropdown-list .entry { display: flex; flex-direction: row; color: var(--box-link-fg-color); align-items: center; gap: 0.5em; padding: 0.5em; } .input-dropdown .input-dropdown-list .entry.recent .edit-button[data-shown-in~=recent] { display: flex; } .input-dropdown .input-dropdown-list .entry.saved .edit-button[data-shown-in~=saved] { display: flex; } .input-dropdown .input-dropdown-list .entry.autocomplete .edit-button[data-shown-in~=autocomplete] { display: flex; } .input-dropdown .input-dropdown-list .entry .edit-button { display: none; } .input-dropdown .input-dropdown-list .entry .search { flex: 1; height: 100%; display: inline-flex; flex-direction: row; flex-wrap: wrap; align-items: center; gap: 0.5em; } .input-dropdown .input-dropdown-list .entry .search .word { display: inline-flex; align-items: center; height: 100%; line-height: 1em; } .input-dropdown .input-dropdown-list .entry .search .word.or { font-size: 12px; color: #aaa; } .input-dropdown .input-dropdown-list .entry.selected { background-color: var(--box-link-selected-color); } @media (hover: hover) { .input-dropdown .input-dropdown-list:not(.editing) .entry:hover { background-color: var(--box-link-hover-color); } .input-dropdown .input-dropdown-list:not(.editing) .entry:hover.selected { background-color: var(--box-link-selected-hover-color); } } .input-dropdown .input-dropdown-list.editing .entry { cursor: default; } .widget.hidden-widget { display: none; } /* The right click context menu for the image view: */ .popup-context-menu { color: #fff; position: fixed; top: 100px; left: 350px; text-align: left; padding: 10px; border-radius: 8px; display: flex; flex-direction: column; z-index: 10; user-select: none; -webkit-user-select: none; will-change: opacity, transform; transition: opacity ease 0.15s, transform ease 0.15s; --context-menu-bg-color: #000; --button-size: 32px; /* Hide the normal tooltips. The context menu shows them differently. */ } .popup-context-menu.hidden-widget { display: inherit; opacity: 0; pointer-events: none; transform: scale(0.85); } .popup-context-menu:not(.hidden-widget) { opacity: 1; } .popup-context-menu .popup:hover:after { display: none; } .popup-context-menu .avatar-widget { width: 100%; height: 100%; } .popup-context-menu .tooltip-display { display: flex; align-items: stretch; padding: 10px 0 0 8px; pointer-events: none; } .popup-context-menu .tooltip-display .tooltip-display-text { background-color: var(--frame-bg-color); color: var(--frame-fg-color); padding: 2px 8px; border-radius: 4px; white-space: nowrap; } .popup-context-menu .tooltip-display .tooltip-display-text:after { content: attr(data-popup); } .popup-context-menu .button-strip { display: flex; /* Remove the double horizontal padding: */ /* Remove the double vertical padding. Do this with a negative margin instead of zeroing * the padding, so the rounded black background stays the same size. */ /* Round the outer corners of each strip. */ } .popup-context-menu .button-strip > .button-block { display: inline-flex; flex-direction: column; background-color: var(--context-menu-bg-color); padding: 12px; width: calc(var(--button-size) + 12px); height: calc(var(--button-size) + 12px); box-sizing: content-box; justify-content: stretch; align-items: center; } .popup-context-menu .button-strip > .button-block:not(:first-child) { padding-left: 0px; } .popup-context-menu .button-strip:not(:last-child) > .button-block { margin-bottom: -12px; } .popup-context-menu .button-strip > .button-block:first-child { border-radius: 5px 0 0 5px; } .popup-context-menu .button-strip > .button-block:last-child { border-radius: 0 5px 5px 0; } .popup-context-menu .button-strip .button { flex: 1; border-radius: 4px; padding: 6px; width: 100%; height: 100%; text-align: center; cursor: pointer; display: flex; flex-direction: column; justify-content: center; background-color: #222; color: #888; /* Grey out the buttons if this strip isn't enabled. */ /* We don't have a way to add classes to inlined SVGs yet, so for now just use nth-child. The first child is the + icon and the second child is -. */ /* Popup menu bookmarking */ } .popup-context-menu .button-strip .button .font-icon { font-size: var(--button-size); } .popup-context-menu .button-strip .button:not(.enabled) { cursor: inherit; color: #666; } .popup-context-menu .button-strip .button > svg { width: 100%; height: 100%; } @media (hover: hover) { .popup-context-menu .button-strip .button.enabled:hover { color: #fff; background-color: #444; } } .popup-context-menu .button-strip .button.enabled.selected { background-color: #444; color: #fff; } .popup-context-menu .button-strip .button.button-zoom:not(.selected) > :nth-child(1) { display: none; } .popup-context-menu .button-strip .button.button-zoom.selected > :nth-child(2) { display: none; } .popup-context-menu .button-strip .button .tag-dropdown-arrow { width: 0; height: 0; border-top: 10px solid #222; border-left: 10px solid transparent; border-right: 10px solid transparent; } .popup-context-menu .button-strip > .button-block.shift-right { margin-left: calc(var(--button-size) + 12px + 12px); } .popup-context-menu .button-strip > .button-block.shift-left { margin-left: calc(-1 * (var(--button-size) + 12px + 12px)); } .popup-context-menu .context-menu-image-info-container { align-self: flex-end; background-color: var(--context-menu-bg-color); padding-right: 8px; user-select: none; -webkit-user-select: none; } .popup-context-menu .context-menu-image-info { display: flex; flex-direction: column; align-items: center; font-size: 0.8em; font-weight: bold; gap: 2px; padding-top: 4px; } .popup-context-menu .popup-bookmark-tag-dropdown { right: -100%; } .popup-more-options-container .button-send-image svg .arrow { transition: transform ease-in-out 0.15s; } .popup-more-options-container .button-send-image:not(.disabled):hover svg .arrow { transform: translate(2px, -2px); } .bookmark-tag-list, .more-options-dropdown { /* In the context menu version, nudge the tag dropdown up slightly to cover * the rounded corners. */ /* Recent bookmark tags in the popup menu: */ } .popup-context-menu .bookmark-tag-list, .popup-context-menu .more-options-dropdown { top: calc(100% - 4px); } .bookmark-tag-list > .tag-list, .more-options-dropdown > .tag-list { min-width: 200px; } .bookmark-tag-list .popup-bookmark-tag-entry, .more-options-dropdown .popup-bookmark-tag-entry { display: flex; flex-direction: row; align-items: center; padding: 0.25em 0.5em; cursor: pointer; background-color: var(--box-link-bg-color); } .bookmark-tag-list .popup-bookmark-tag-entry > .tag-name, .more-options-dropdown .popup-bookmark-tag-entry > .tag-name { flex: 1; display: flex; gap: 0.5em; } .bookmark-tag-list .popup-bookmark-tag-entry.selected, .more-options-dropdown .popup-bookmark-tag-entry.selected { background-color: var(--box-link-selected-color); } @media (hover: hover) { .bookmark-tag-list .popup-bookmark-tag-entry:hover, .more-options-dropdown .popup-bookmark-tag-entry:hover { background-color: var(--box-link-hover-color); } .bookmark-tag-list .popup-bookmark-tag-entry:hover.selected, .more-options-dropdown .popup-bookmark-tag-entry:hover.selected { background-color: var(--box-link-selected-hover-color); } } .mobile-illust-ui-dialog { --box-link-bg-color: none; } .mobile-illust-ui-container { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; pointer-events: none; --pointer-events: none; --frame-bg-color: rgba(0,0,0,.65); --box-link-bg-color: none; } .mobile-illust-ui-container.fully-visible { --pointer-events: auto; } .mobile-illust-ui-container .mobile-illust-ui-page { width: 100%; display: flex; flex-direction: column; align-items: stretch; user-select: none; -webkit-user-select: none; translate: 0 calc((var(--menu-bar-pos) - 0) * (0px - var(--menu-bar-height)) / 1 + var(--menu-bar-height)); } .mobile-illust-ui-container .mobile-illust-ui-page .avatar { align-self: center; } .mobile-illust-ui-container .mobile-illust-ui-page .avatar-widget { opacity: var(--menu-bar-pos); pointer-events: var(--pointer-events); margin-bottom: 1em; width: min(170px, 50vmin); } .mobile-illust-ui-container .mobile-illust-ui-page .avatar-widget > .avatar { border: 2px solid #000; border-radius: 100%; } .mobile-illust-ui-container .mobile-illust-ui-page .avatar-widget .follow-icon { bottom: 0; right: 0; } .mobile-illust-ui-container .mobile-illust-ui-page .menu-bar { opacity: var(--menu-bar-pos); pointer-events: var(--pointer-events); padding-top: 0.5em; padding-bottom: max(env(safe-area-inset-bottom), 0.5em); padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); font-size: 65%; display: flex; flex-direction: row; justify-content: space-evenly; } .mobile-illust-ui-container .item { display: flex; flex-direction: column; align-items: center; gap: 0.25em; padding: 0 0.25em; justify-content: center; --foreground-color: #aaa; color: var(--foreground-color); /* Popup menu bookmarking */ } .mobile-illust-ui-container .item.enabled { --foreground-color: #fff; } .mobile-illust-ui-container .item.selected { --foreground-color: #ff8; } .mobile-illust-ui-container .item > svg { width: 2em; height: 2em; } .mobile-illust-ui-container .item .label { text-align: center; font-size: 0.7rem; } .mobile-illust-ui-container .item .tag-dropdown-arrow { width: 0; height: 0; border-top: 10px solid #222; border-left: 10px solid transparent; border-right: 10px solid transparent; } .mobile-illust-ui-container .item .font-icon { font-size: 200%; } .mobile-image-info { display: flex; flex-direction: column; align-items: center; text-align: center; } .mobile-image-info .author-block { display: inline-flex; align-items: center; } .mobile-image-info .author-block .avatar-widget { height: 4em; margin-right: 0.5em; } .mobile-image-info .description { display: inline-block; width: 100%; background-color: #000; padding: 0.5em 0.5em; margin: 0.5em 0; border: 1px solid #aaa; border-radius: 0.5em; overflow-wrap: break-word; } .mobile-image-info .bookmark-tags { margin-top: 0.25em; display: flex; flex-wrap: wrap; flex-direction: row; gap: 0.25em 0.5em; justify-content: center; } .mobile-image-info .bookmark-tags .mobile-ui-tag-entry { white-space: nowrap; background-color: var(--frame-bg-color); padding: 0.5em 0.75em; border-radius: 0.5em; line-height: 1.5em; flex: 1; } .mobile-image-info .bookmark-tags .mobile-ui-tag-entry .bookmark-tag-icon { vertical-align: middle; } .button-bookmark, .button-like { position: relative; } .button-bookmark .count, .button-like .count { color: var(--minor-text-fg-color); text-shadow: 0px 1px 1px var(--minor-text-shadow-color), 0px -1px 1px var(--minor-text-shadow-color), 1px 0px 1px var(--minor-text-shadow-color), -1px 0px 1px var(--minor-text-shadow-color); font-size: 0.7em; font-weight: bold; position: absolute; top: calc(100% - 14px); left: 0; width: 100%; text-align: center; } /* Nudge the public heart icon up a bit to make room for the bookmark count. * Only do this on the popup menu, not image-ui. */ .popup-context-menu .button-bookmark[data-bookmark-type=public] .has-like-count > svg { margin-top: -10px; } .popup-context-menu .button.button-like > svg { margin-top: -2px; } /* Hide the "delete" stroke over the heart icon unless clicking the button will * remove the bookmark. */ svg.heart-image .delete { display: none; } @media (hover: hover) { .button-bookmark.button.will-delete.enabled:hover svg.heart-image .delete { display: inline; } } .button-bookmark svg { color: #400 !important; } .button-bookmark.enabled svg { color: #800 !important; stroke: none; } .button-bookmark.bookmarked svg { color: #f00 !important; stroke: none; } @media (hover: hover) { .button-bookmark.enabled:hover svg { color: #f00 !important; stroke: none; } } .button.button-like { /* This is a pain due to transition bugs in Firefox. It doesn't like having * transition: transform on both an SVG and on individual paths inside the * SVG and clips the image incorrectly during the animation. Work around this * by only placing transitions on the paths. */ } .button.button-like > svg { color: var(--like-button-color); } .button.button-like.liked > svg { color: var(--like-button-liked-color); } @media (hover: hover) { .button.button-like.enabled:hover > svg { color: var(--like-button-hover-color); } } .button.button-browser-back .arrow { transition: transform ease-in-out 0.15s; transform: translate(-2px, 0px); } @media (hover: hover) { .button.button-browser-back:hover .arrow { transform: translate(1px, 0px); } } .button.button-like { --overall-translate-x: 0; --overall-translate-y: 0; --mouth-scale-x: 1; --mouth-scale-y: 0.75; --mouth-translate-x: 0; --mouth-translate-y: 0; } .button.button-like.liked { --mouth-scale-x: 1; --mouth-scale-y: 1.1; --mouth-translate-x: 0; --mouth-translate-y: -3px; --overall-translate-x: 0; --overall-translate-y: -3px; } @media (hover: hover) { .button.button-like.enabled:hover { --overall-translate-x: 0; --overall-translate-y: -2px; --mouth-scale-x: 1; --mouth-scale-y: 0.9; --mouth-translate-x: 0; --mouth-translate-y: -3px; } } .button.button-like > svg > * { transition: transform ease-in-out 0.15s; transform: translate(var(--overall-translate-x), var(--overall-translate-y)); } .button.button-like > svg > .mouth { transform: scale(var(--mouth-scale-x), var(--mouth-scale-y)) translate(var(--mouth-translate-x), var(--mouth-translate-y)); } .button-bookmark.public svg.heart-image .lock { display: none; } .button-bookmark svg.heart-image .lock { stroke: #888; } .dialog-normal { position: fixed; z-index: 1000; inset: 0; overscroll-behavior: contain; --dialog-visible: 1; display: flex; align-items: flex-end; justify-content: center; --dialog-bg-color: rgba(0,0,0, var(--dialog-bg-alpha)); background-color: rgba(0, 0, 0, min(var(--dialog-backdrop-alpha), max(0, (var(--dialog-visible) - 0) * (var(--dialog-backdrop-alpha) - 0) / 1 + 0))); --edge-padding-inner-horiz: 20px; --edge-padding-inner-vert: 20px; --edge-padding-outer-horiz: 40px; --edge-padding-outer-vert: 20px; padding: var(--dialog-outer-padding-top) var(--dialog-outer-padding-right) var(--dialog-outer-padding-bottom) var(--dialog-outer-padding-left); } html.ios .dialog-normal { inset: -1px 0; } .dialog-normal.floating { align-items: center; } .dialog-normal:not(.floating) { --dialog-bg-alpha: 0.5; --dialog-backdrop-alpha: 0.5; } .dialog-normal.floating { --dialog-bg-alpha: 0.75; --dialog-backdrop-alpha: 0.5; } html.mobile .dialog-normal { font-size: 1.2rem; } html.mobile .dialog-normal { --edge-padding-inner-horiz: 10px; --edge-padding-inner-vert: 10px; } .dialog-normal.floating { --dialog-outer-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-outer-horiz))); --dialog-outer-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-outer-horiz))); --dialog-outer-padding-top: calc(max(env(safe-area-inset-top), var(--edge-padding-outer-vert))); --dialog-outer-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-outer-vert))); --dialog-inner-padding-left: var(--edge-padding-inner-horiz); --dialog-inner-padding-right: var(--edge-padding-inner-horiz); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: var(--edge-padding-inner-vert); } .dialog-normal.floating .dialog { opacity: min(1, max(0, (var(--dialog-visible) - 0) * 1 / 1 + 0)); } .dialog-normal:not(.floating) { --edge-padding-horiz: 20px; --edge-padding-vert: 20px; --dialog-extra-top-padding: 2vh; --dialog-outer-padding-left: 0px; --dialog-outer-padding-right: 0px; --dialog-outer-padding-top: calc(env(safe-area-inset-top) + var(--dialog-extra-top-padding)); --dialog-outer-padding-bottom: 0px; --dialog-inner-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-inner-horiz))); --dialog-inner-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-inner-horiz))); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-inner-vert))); } .dialog-normal:not(.floating) .dialog { border-radius: 25px 25px 0 0; overflow: hidden; } .dialog-normal > .dialog { padding: var(--dialog-inner-padding-top) var(--dialog-inner-padding-right) var(--dialog-inner-padding-bottom) var(--dialog-inner-padding-left); transform: translateY(calc((var(--dialog-visible) - 0) * -100% / 1 + 100%)); } .dialog-normal > .dialog .scroll { overflow-x: hidden; overflow-y: auto; overflow-y: overlay; touch-action: pan-y; margin-right: calc(var(--dialog-inner-padding-right) * -1); padding-right: calc(var(--dialog-inner-padding-right)); margin-left: calc(var(--dialog-inner-padding-left) * -1); padding-left: calc(var(--dialog-inner-padding-left)); margin-bottom: calc(var(--dialog-inner-padding-bottom) * -1); padding-bottom: calc(var(--dialog-inner-padding-bottom)); } .dialog-normal.floating .dialog { min-width: 20em; max-width: min(45em, 90vw); max-height: 90vh; border-radius: 5px; } .dialog-normal.small .dialog { min-width: 10em; max-width: 45em; } .dialog-normal:not(.floating) .dialog { width: 100%; } .dialog-normal .dialog { max-height: 100%; background-color: var(--dialog-bg-color); color: var(--ui-fg-color); position: relative; display: flex; flex-direction: column; } .dialog-normal.dragging-dialog > .dialog { pointer-events: none; } .dialog-normal.dragging-dialog .vertical-scroller { overflow: hidden !important; } .dialog-normal .header { --icon-size: 2; line-height: 2em; display: flex; align-items: center; } .dialog-normal .header .close-button-container { flex: 1; } .dialog-normal .header .close-button-container .close-button { --icon-size: 1.5; color: var(--button-color); cursor: pointer; justify-self: center; } @media (hover: hover) { .dialog-normal .header .close-button-container .close-button:hover { color: var(--button-highlight-color); } } .dialog-normal .header .close-button-container .close-button > svg { display: block; } .dialog-normal .header .header-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; } .dialog-normal .header .center-header-helper { width: calc(1em * var(--icon-size) + 1em); flex: 1; flex-shrink: 100000; } .dialog-small { position: fixed; z-index: 1000; inset: 0; overscroll-behavior: contain; --dialog-visible: 1; display: flex; align-items: flex-end; justify-content: center; --dialog-bg-color: rgba(0,0,0, var(--dialog-bg-alpha)); background-color: rgba(0, 0, 0, min(var(--dialog-backdrop-alpha), max(0, (var(--dialog-visible) - 0) * (var(--dialog-backdrop-alpha) - 0) / 1 + 0))); --edge-padding-inner-horiz: 20px; --edge-padding-inner-vert: 20px; --edge-padding-outer-horiz: 40px; --edge-padding-outer-vert: 20px; padding: var(--dialog-outer-padding-top) var(--dialog-outer-padding-right) var(--dialog-outer-padding-bottom) var(--dialog-outer-padding-left); } html.ios .dialog-small { inset: -1px 0; } .dialog-small.floating { align-items: center; } .dialog-small:not(.floating) { --dialog-bg-alpha: 0.5; --dialog-backdrop-alpha: 0.5; } .dialog-small.floating { --dialog-bg-alpha: 0.75; --dialog-backdrop-alpha: 0.5; } html.mobile .dialog-small { font-size: 1.2rem; } html.mobile .dialog-small { --edge-padding-inner-horiz: 10px; --edge-padding-inner-vert: 10px; } .dialog-small.floating { --dialog-outer-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-outer-horiz))); --dialog-outer-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-outer-horiz))); --dialog-outer-padding-top: calc(max(env(safe-area-inset-top), var(--edge-padding-outer-vert))); --dialog-outer-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-outer-vert))); --dialog-inner-padding-left: var(--edge-padding-inner-horiz); --dialog-inner-padding-right: var(--edge-padding-inner-horiz); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: var(--edge-padding-inner-vert); } .dialog-small.floating .dialog { opacity: min(1, max(0, (var(--dialog-visible) - 0) * 1 / 1 + 0)); } .dialog-small:not(.floating) { --edge-padding-horiz: 20px; --edge-padding-vert: 20px; --dialog-extra-top-padding: 2vh; --dialog-outer-padding-left: 0px; --dialog-outer-padding-right: 0px; --dialog-outer-padding-top: calc(env(safe-area-inset-top) + var(--dialog-extra-top-padding)); --dialog-outer-padding-bottom: 0px; --dialog-inner-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-inner-horiz))); --dialog-inner-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-inner-horiz))); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-inner-vert))); } .dialog-small:not(.floating) .dialog { border-radius: 25px 25px 0 0; overflow: hidden; } .dialog-small > .dialog { padding: var(--dialog-inner-padding-top) var(--dialog-inner-padding-right) var(--dialog-inner-padding-bottom) var(--dialog-inner-padding-left); transform: translateY(calc((var(--dialog-visible) - 0) * -100% / 1 + 100%)); } .dialog-small > .dialog .scroll { overflow-x: hidden; overflow-y: auto; overflow-y: overlay; touch-action: pan-y; margin-right: calc(var(--dialog-inner-padding-right) * -1); padding-right: calc(var(--dialog-inner-padding-right)); margin-left: calc(var(--dialog-inner-padding-left) * -1); padding-left: calc(var(--dialog-inner-padding-left)); margin-bottom: calc(var(--dialog-inner-padding-bottom) * -1); padding-bottom: calc(var(--dialog-inner-padding-bottom)); } .dialog-small.floating .dialog { min-width: 20em; max-width: min(45em, 90vw); max-height: 90vh; border-radius: 5px; } .dialog-small.small .dialog { min-width: 10em; max-width: 45em; } .dialog-small:not(.floating) .dialog { width: 100%; } .dialog-small .dialog { max-height: 100%; background-color: var(--dialog-bg-color); color: var(--ui-fg-color); position: relative; display: flex; flex-direction: column; } .dialog-small.dragging-dialog > .dialog { pointer-events: none; } .dialog-small.dragging-dialog .vertical-scroller { overflow: hidden !important; } .dialog-small .header { --icon-size: 2; line-height: 2em; display: flex; align-items: center; } .dialog-small .header .close-button-container { flex: 1; } .dialog-small .header .close-button-container .close-button { --icon-size: 1.5; color: var(--button-color); cursor: pointer; justify-self: center; } @media (hover: hover) { .dialog-small .header .close-button-container .close-button:hover { color: var(--button-highlight-color); } } .dialog-small .header .close-button-container .close-button > svg { display: block; } .dialog-small .header .header-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; } .dialog-small .header .center-header-helper { width: calc(1em * var(--icon-size) + 1em); flex: 1; flex-shrink: 100000; } .simple-button-dialog { position: fixed; z-index: 1000; inset: 0; overscroll-behavior: contain; --dialog-visible: 1; display: flex; align-items: flex-end; justify-content: center; --dialog-bg-color: rgba(0,0,0, var(--dialog-bg-alpha)); background-color: rgba(0, 0, 0, min(var(--dialog-backdrop-alpha), max(0, (var(--dialog-visible) - 0) * (var(--dialog-backdrop-alpha) - 0) / 1 + 0))); --edge-padding-inner-horiz: 20px; --edge-padding-inner-vert: 20px; --edge-padding-outer-horiz: 40px; --edge-padding-outer-vert: 20px; padding: var(--dialog-outer-padding-top) var(--dialog-outer-padding-right) var(--dialog-outer-padding-bottom) var(--dialog-outer-padding-left); background: rgba(0, 0, 0, 0.2666666667); } html.ios .simple-button-dialog { inset: -1px 0; } .simple-button-dialog.floating { align-items: center; } .simple-button-dialog:not(.floating) { --dialog-bg-alpha: 0.5; --dialog-backdrop-alpha: 0.5; } .simple-button-dialog.floating { --dialog-bg-alpha: 0.75; --dialog-backdrop-alpha: 0.5; } html.mobile .simple-button-dialog { font-size: 1.2rem; } html.mobile .simple-button-dialog { --edge-padding-inner-horiz: 10px; --edge-padding-inner-vert: 10px; } .simple-button-dialog.floating { --dialog-outer-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-outer-horiz))); --dialog-outer-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-outer-horiz))); --dialog-outer-padding-top: calc(max(env(safe-area-inset-top), var(--edge-padding-outer-vert))); --dialog-outer-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-outer-vert))); --dialog-inner-padding-left: var(--edge-padding-inner-horiz); --dialog-inner-padding-right: var(--edge-padding-inner-horiz); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: var(--edge-padding-inner-vert); } .simple-button-dialog.floating .dialog { opacity: min(1, max(0, (var(--dialog-visible) - 0) * 1 / 1 + 0)); } .simple-button-dialog:not(.floating) { --edge-padding-horiz: 20px; --edge-padding-vert: 20px; --dialog-extra-top-padding: 2vh; --dialog-outer-padding-left: 0px; --dialog-outer-padding-right: 0px; --dialog-outer-padding-top: calc(env(safe-area-inset-top) + var(--dialog-extra-top-padding)); --dialog-outer-padding-bottom: 0px; --dialog-inner-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-inner-horiz))); --dialog-inner-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-inner-horiz))); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-inner-vert))); } .simple-button-dialog:not(.floating) .dialog { border-radius: 25px 25px 0 0; overflow: hidden; } .simple-button-dialog > .dialog { padding: var(--dialog-inner-padding-top) var(--dialog-inner-padding-right) var(--dialog-inner-padding-bottom) var(--dialog-inner-padding-left); transform: translateY(calc((var(--dialog-visible) - 0) * -100% / 1 + 100%)); } .simple-button-dialog > .dialog .scroll { overflow-x: hidden; overflow-y: auto; overflow-y: overlay; touch-action: pan-y; margin-right: calc(var(--dialog-inner-padding-right) * -1); padding-right: calc(var(--dialog-inner-padding-right)); margin-left: calc(var(--dialog-inner-padding-left) * -1); padding-left: calc(var(--dialog-inner-padding-left)); margin-bottom: calc(var(--dialog-inner-padding-bottom) * -1); padding-bottom: calc(var(--dialog-inner-padding-bottom)); } .simple-button-dialog.floating .dialog { min-width: 20em; max-width: min(45em, 90vw); max-height: 90vh; border-radius: 5px; } .simple-button-dialog.small .dialog { min-width: 10em; max-width: 45em; } .simple-button-dialog:not(.floating) .dialog { width: 100%; } .simple-button-dialog .dialog { max-height: 100%; background-color: var(--dialog-bg-color); color: var(--ui-fg-color); position: relative; display: flex; flex-direction: column; } .simple-button-dialog.dragging-dialog > .dialog { pointer-events: none; } .simple-button-dialog.dragging-dialog .vertical-scroller { overflow: hidden !important; } .simple-button-dialog .header { --icon-size: 2; line-height: 2em; display: flex; align-items: center; } .simple-button-dialog .header .close-button-container { flex: 1; } .simple-button-dialog .header .close-button-container .close-button { --icon-size: 1.5; color: var(--button-color); cursor: pointer; justify-self: center; } @media (hover: hover) { .simple-button-dialog .header .close-button-container .close-button:hover { color: var(--button-highlight-color); } } .simple-button-dialog .header .close-button-container .close-button > svg { display: block; } .simple-button-dialog .header .header-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; } .simple-button-dialog .header .center-header-helper { width: calc(1em * var(--icon-size) + 1em); flex: 1; flex-shrink: 100000; } .simple-button-dialog .dialog { padding: 0; background: none; --box-link-bg-color: #0004; --box-link-hover-color: #0008; } .simple-button-dialog .dialog .box-link { padding: 1.5em 2em; border-radius: 0.5em; } .whats-new-dialog { display: flex; flex-direction: column; } .whats-new-dialog .font-icon { vertical-align: bottom; } .whats-new-dialog .rev { display: inline-block; color: var(--box-link-fg-color); background-color: var(--box-link-bg-color); padding: 5px 10px; } .whats-new-dialog .text { margin: 1em 0; padding: 0 20px; /* inset horizontally a bit */ } .whats-new-dialog .explanation-button { cursor: pointer; text-decoration: underline; } .whats-new-dialog .explanation-target { margin: 0 1em; } .mobile-tag-list { position: fixed; z-index: 1000; inset: 0; overscroll-behavior: contain; --dialog-visible: 1; display: flex; align-items: flex-end; justify-content: center; --dialog-bg-color: rgba(0,0,0, var(--dialog-bg-alpha)); background-color: rgba(0, 0, 0, min(var(--dialog-backdrop-alpha), max(0, (var(--dialog-visible) - 0) * (var(--dialog-backdrop-alpha) - 0) / 1 + 0))); --edge-padding-inner-horiz: 20px; --edge-padding-inner-vert: 20px; --edge-padding-outer-horiz: 40px; --edge-padding-outer-vert: 20px; padding: var(--dialog-outer-padding-top) var(--dialog-outer-padding-right) var(--dialog-outer-padding-bottom) var(--dialog-outer-padding-left); --box-link-bg-color: none; } html.ios .mobile-tag-list { inset: -1px 0; } .mobile-tag-list.floating { align-items: center; } .mobile-tag-list:not(.floating) { --dialog-bg-alpha: 0.5; --dialog-backdrop-alpha: 0.5; } .mobile-tag-list.floating { --dialog-bg-alpha: 0.75; --dialog-backdrop-alpha: 0.5; } html.mobile .mobile-tag-list { font-size: 1.2rem; } html.mobile .mobile-tag-list { --edge-padding-inner-horiz: 10px; --edge-padding-inner-vert: 10px; } .mobile-tag-list.floating { --dialog-outer-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-outer-horiz))); --dialog-outer-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-outer-horiz))); --dialog-outer-padding-top: calc(max(env(safe-area-inset-top), var(--edge-padding-outer-vert))); --dialog-outer-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-outer-vert))); --dialog-inner-padding-left: var(--edge-padding-inner-horiz); --dialog-inner-padding-right: var(--edge-padding-inner-horiz); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: var(--edge-padding-inner-vert); } .mobile-tag-list.floating .dialog { opacity: min(1, max(0, (var(--dialog-visible) - 0) * 1 / 1 + 0)); } .mobile-tag-list:not(.floating) { --edge-padding-horiz: 20px; --edge-padding-vert: 20px; --dialog-extra-top-padding: 2vh; --dialog-outer-padding-left: 0px; --dialog-outer-padding-right: 0px; --dialog-outer-padding-top: calc(env(safe-area-inset-top) + var(--dialog-extra-top-padding)); --dialog-outer-padding-bottom: 0px; --dialog-inner-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-inner-horiz))); --dialog-inner-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-inner-horiz))); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-inner-vert))); } .mobile-tag-list:not(.floating) .dialog { border-radius: 25px 25px 0 0; overflow: hidden; } .mobile-tag-list > .dialog { padding: var(--dialog-inner-padding-top) var(--dialog-inner-padding-right) var(--dialog-inner-padding-bottom) var(--dialog-inner-padding-left); transform: translateY(calc((var(--dialog-visible) - 0) * -100% / 1 + 100%)); } .mobile-tag-list > .dialog .scroll { overflow-x: hidden; overflow-y: auto; overflow-y: overlay; touch-action: pan-y; margin-right: calc(var(--dialog-inner-padding-right) * -1); padding-right: calc(var(--dialog-inner-padding-right)); margin-left: calc(var(--dialog-inner-padding-left) * -1); padding-left: calc(var(--dialog-inner-padding-left)); margin-bottom: calc(var(--dialog-inner-padding-bottom) * -1); padding-bottom: calc(var(--dialog-inner-padding-bottom)); } .mobile-tag-list.floating .dialog { min-width: 20em; max-width: min(45em, 90vw); max-height: 90vh; border-radius: 5px; } .mobile-tag-list.small .dialog { min-width: 10em; max-width: 45em; } .mobile-tag-list:not(.floating) .dialog { width: 100%; } .mobile-tag-list .dialog { max-height: 100%; background-color: var(--dialog-bg-color); color: var(--ui-fg-color); position: relative; display: flex; flex-direction: column; } .mobile-tag-list.dragging-dialog > .dialog { pointer-events: none; } .mobile-tag-list.dragging-dialog .vertical-scroller { overflow: hidden !important; } .mobile-tag-list .header { --icon-size: 2; line-height: 2em; display: flex; align-items: center; } .mobile-tag-list .header .close-button-container { flex: 1; } .mobile-tag-list .header .close-button-container .close-button { --icon-size: 1.5; color: var(--button-color); cursor: pointer; justify-self: center; } @media (hover: hover) { .mobile-tag-list .header .close-button-container .close-button:hover { color: var(--button-highlight-color); } } .mobile-tag-list .header .close-button-container .close-button > svg { display: block; } .mobile-tag-list .header .header-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; } .mobile-tag-list .header .center-header-helper { width: calc(1em * var(--icon-size) + 1em); flex: 1; flex-shrink: 100000; } .mobile-tag-list > .dialog { padding-bottom: 0; } .mobile-tag-list > .dialog > .scroll { margin-bottom: 0; padding-bottom: 0; } .mobile-tag-list .popup-bookmark-tag-entry { font-size: 120%; min-width: 20em; } .mobile-tag-list .menu-bar { --icon-size: 1.6; position: sticky; bottom: 0; display: flex; flex-direction: row; justify-content: space-evenly; align-items: center; padding: 8px 0; padding-bottom: var(--dialog-inner-padding-bottom); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); } .mobile-tag-list .menu-bar .button-bookmark.public, .mobile-tag-list .menu-bar .button-bookmark.private { margin-bottom: -0.25em; } .text-entry-popup { position: fixed; z-index: 1000; inset: 0; overscroll-behavior: contain; --dialog-visible: 1; display: flex; align-items: flex-end; justify-content: center; --dialog-bg-color: rgba(0,0,0, var(--dialog-bg-alpha)); background-color: rgba(0, 0, 0, min(var(--dialog-backdrop-alpha), max(0, (var(--dialog-visible) - 0) * (var(--dialog-backdrop-alpha) - 0) / 1 + 0))); --edge-padding-inner-horiz: 20px; --edge-padding-inner-vert: 20px; --edge-padding-outer-horiz: 40px; --edge-padding-outer-vert: 20px; padding: var(--dialog-outer-padding-top) var(--dialog-outer-padding-right) var(--dialog-outer-padding-bottom) var(--dialog-outer-padding-left); } html.ios .text-entry-popup { inset: -1px 0; } .text-entry-popup.floating { align-items: center; } .text-entry-popup:not(.floating) { --dialog-bg-alpha: 0.5; --dialog-backdrop-alpha: 0.5; } .text-entry-popup.floating { --dialog-bg-alpha: 0.75; --dialog-backdrop-alpha: 0.5; } html.mobile .text-entry-popup { font-size: 1.2rem; } html.mobile .text-entry-popup { --edge-padding-inner-horiz: 10px; --edge-padding-inner-vert: 10px; } .text-entry-popup.floating { --dialog-outer-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-outer-horiz))); --dialog-outer-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-outer-horiz))); --dialog-outer-padding-top: calc(max(env(safe-area-inset-top), var(--edge-padding-outer-vert))); --dialog-outer-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-outer-vert))); --dialog-inner-padding-left: var(--edge-padding-inner-horiz); --dialog-inner-padding-right: var(--edge-padding-inner-horiz); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: var(--edge-padding-inner-vert); } .text-entry-popup.floating .dialog { opacity: min(1, max(0, (var(--dialog-visible) - 0) * 1 / 1 + 0)); } .text-entry-popup:not(.floating) { --edge-padding-horiz: 20px; --edge-padding-vert: 20px; --dialog-extra-top-padding: 2vh; --dialog-outer-padding-left: 0px; --dialog-outer-padding-right: 0px; --dialog-outer-padding-top: calc(env(safe-area-inset-top) + var(--dialog-extra-top-padding)); --dialog-outer-padding-bottom: 0px; --dialog-inner-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-inner-horiz))); --dialog-inner-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-inner-horiz))); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-inner-vert))); } .text-entry-popup:not(.floating) .dialog { border-radius: 25px 25px 0 0; overflow: hidden; } .text-entry-popup > .dialog { padding: var(--dialog-inner-padding-top) var(--dialog-inner-padding-right) var(--dialog-inner-padding-bottom) var(--dialog-inner-padding-left); transform: translateY(calc((var(--dialog-visible) - 0) * -100% / 1 + 100%)); } .text-entry-popup > .dialog .scroll { overflow-x: hidden; overflow-y: auto; overflow-y: overlay; touch-action: pan-y; margin-right: calc(var(--dialog-inner-padding-right) * -1); padding-right: calc(var(--dialog-inner-padding-right)); margin-left: calc(var(--dialog-inner-padding-left) * -1); padding-left: calc(var(--dialog-inner-padding-left)); margin-bottom: calc(var(--dialog-inner-padding-bottom) * -1); padding-bottom: calc(var(--dialog-inner-padding-bottom)); } .text-entry-popup.floating .dialog { min-width: 20em; max-width: min(45em, 90vw); max-height: 90vh; border-radius: 5px; } .text-entry-popup.small .dialog { min-width: 10em; max-width: 45em; } .text-entry-popup:not(.floating) .dialog { width: 100%; } .text-entry-popup .dialog { max-height: 100%; background-color: var(--dialog-bg-color); color: var(--ui-fg-color); position: relative; display: flex; flex-direction: column; } .text-entry-popup.dragging-dialog > .dialog { pointer-events: none; } .text-entry-popup.dragging-dialog .vertical-scroller { overflow: hidden !important; } .text-entry-popup .header { --icon-size: 2; line-height: 2em; display: flex; align-items: center; } .text-entry-popup .header .close-button-container { flex: 1; } .text-entry-popup .header .close-button-container .close-button { --icon-size: 1.5; color: var(--button-color); cursor: pointer; justify-self: center; } @media (hover: hover) { .text-entry-popup .header .close-button-container .close-button:hover { color: var(--button-highlight-color); } } .text-entry-popup .header .close-button-container .close-button > svg { display: block; } .text-entry-popup .header .header-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; } .text-entry-popup .header .center-header-helper { width: calc(1em * var(--icon-size) + 1em); flex: 1; flex-shrink: 100000; } .text-entry-popup .header { font-size: 20px; } .text-entry-popup .input-box { position: relative; display: flex; align-items: center; } .text-entry-popup .input-box > .editor { flex: 1; padding: 4px; min-width: min(50vw, 25em); white-space: pre-wrap; } .text-entry-popup .input-box > .submit-button { cursor: pointer; display: flex; margin-left: 6px; padding: 0.25em; border: 1px solid white; } @media (hover: hover) { .text-entry-popup .input-box > .submit-button:hover { background-color: #444; } } .confirm-dialog { position: fixed; z-index: 1000; inset: 0; overscroll-behavior: contain; --dialog-visible: 1; display: flex; align-items: flex-end; justify-content: center; --dialog-bg-color: rgba(0,0,0, var(--dialog-bg-alpha)); background-color: rgba(0, 0, 0, min(var(--dialog-backdrop-alpha), max(0, (var(--dialog-visible) - 0) * (var(--dialog-backdrop-alpha) - 0) / 1 + 0))); --edge-padding-inner-horiz: 20px; --edge-padding-inner-vert: 20px; --edge-padding-outer-horiz: 40px; --edge-padding-outer-vert: 20px; padding: var(--dialog-outer-padding-top) var(--dialog-outer-padding-right) var(--dialog-outer-padding-bottom) var(--dialog-outer-padding-left); } html.ios .confirm-dialog { inset: -1px 0; } .confirm-dialog.floating { align-items: center; } .confirm-dialog:not(.floating) { --dialog-bg-alpha: 0.5; --dialog-backdrop-alpha: 0.5; } .confirm-dialog.floating { --dialog-bg-alpha: 0.75; --dialog-backdrop-alpha: 0.5; } html.mobile .confirm-dialog { font-size: 1.2rem; } html.mobile .confirm-dialog { --edge-padding-inner-horiz: 10px; --edge-padding-inner-vert: 10px; } .confirm-dialog.floating { --dialog-outer-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-outer-horiz))); --dialog-outer-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-outer-horiz))); --dialog-outer-padding-top: calc(max(env(safe-area-inset-top), var(--edge-padding-outer-vert))); --dialog-outer-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-outer-vert))); --dialog-inner-padding-left: var(--edge-padding-inner-horiz); --dialog-inner-padding-right: var(--edge-padding-inner-horiz); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: var(--edge-padding-inner-vert); } .confirm-dialog.floating .dialog { opacity: min(1, max(0, (var(--dialog-visible) - 0) * 1 / 1 + 0)); } .confirm-dialog:not(.floating) { --edge-padding-horiz: 20px; --edge-padding-vert: 20px; --dialog-extra-top-padding: 2vh; --dialog-outer-padding-left: 0px; --dialog-outer-padding-right: 0px; --dialog-outer-padding-top: calc(env(safe-area-inset-top) + var(--dialog-extra-top-padding)); --dialog-outer-padding-bottom: 0px; --dialog-inner-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-inner-horiz))); --dialog-inner-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-inner-horiz))); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-inner-vert))); } .confirm-dialog:not(.floating) .dialog { border-radius: 25px 25px 0 0; overflow: hidden; } .confirm-dialog > .dialog { padding: var(--dialog-inner-padding-top) var(--dialog-inner-padding-right) var(--dialog-inner-padding-bottom) var(--dialog-inner-padding-left); transform: translateY(calc((var(--dialog-visible) - 0) * -100% / 1 + 100%)); } .confirm-dialog > .dialog .scroll { overflow-x: hidden; overflow-y: auto; overflow-y: overlay; touch-action: pan-y; margin-right: calc(var(--dialog-inner-padding-right) * -1); padding-right: calc(var(--dialog-inner-padding-right)); margin-left: calc(var(--dialog-inner-padding-left) * -1); padding-left: calc(var(--dialog-inner-padding-left)); margin-bottom: calc(var(--dialog-inner-padding-bottom) * -1); padding-bottom: calc(var(--dialog-inner-padding-bottom)); } .confirm-dialog.floating .dialog { min-width: 20em; max-width: min(45em, 90vw); max-height: 90vh; border-radius: 5px; } .confirm-dialog.small .dialog { min-width: 10em; max-width: 45em; } .confirm-dialog:not(.floating) .dialog { width: 100%; } .confirm-dialog .dialog { max-height: 100%; background-color: var(--dialog-bg-color); color: var(--ui-fg-color); position: relative; display: flex; flex-direction: column; } .confirm-dialog.dragging-dialog > .dialog { pointer-events: none; } .confirm-dialog.dragging-dialog .vertical-scroller { overflow: hidden !important; } .confirm-dialog .header { --icon-size: 2; line-height: 2em; display: flex; align-items: center; } .confirm-dialog .header .close-button-container { flex: 1; } .confirm-dialog .header .close-button-container .close-button { --icon-size: 1.5; color: var(--button-color); cursor: pointer; justify-self: center; } @media (hover: hover) { .confirm-dialog .header .close-button-container .close-button:hover { color: var(--button-highlight-color); } } .confirm-dialog .header .close-button-container .close-button > svg { display: block; } .confirm-dialog .header .header-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; } .confirm-dialog .header .center-header-helper { width: calc(1em * var(--icon-size) + 1em); flex: 1; flex-shrink: 100000; } .confirm-dialog .scroll { display: flex; flex-direction: column; align-items: center; } .confirm-dialog .scroll .text { text-align: center; padding: 1em 0; } .confirm-dialog .scroll .input-box { display: flex; align-items: center; gap: 2em; } .screen-illust-container { position: fixed; inset: 0; z-index: 1; --illust-hidden: 1; } .screen-illust-container .view-container { user-select: none; -webkit-user-select: none; cursor: pointer; translate: 0px calc(var(--menu-bar-pos) * var(--menu-bar-height) * -1); } .screen-illust-container .view-container .viewer { position: absolute; inset: var(--fullscreen-top) var(--fullscreen-right) var(--fullscreen-bottom) var(--fullscreen-left); } .screen-illust-container .view-container .viewer.hidden-widget { display: unset; visibility: hidden; pointer-events: none; } .screen-illust-container .fade-search { position: fixed; z-index: -1; inset: -100vh; pointer-events: none; background-color: rgba(0, 0, 0, calc((var(--illust-hidden) - 0.1) * -1 / 0.9 + 1)); } .screen-illust-container .view-container { position: absolute; overflow: hidden; inset: 0; transform: translateX(calc(var(--illust-hidden) * var(--animation-x))) translateY(calc(var(--illust-hidden) * var(--animation-y))) scale(calc((var(--illust-hidden) - 0) * (var(--animation-scale) - 1) / 1 + 1)); transform-origin: 0% 0%; opacity: calc((var(--illust-hidden) - 0) * -1 / 1 + 1); } html.mobile .screen-illust-container .view-container .viewer .rounded-box { overflow: hidden; --rounding-amount: 1; border-radius: calc(var(--device-edge-radius) * var(--rounding-amount)); will-change: transform; } .screen-illust-container .page-change-indicator { position: absolute; height: 100%; display: flex; align-items: center; pointer-events: none; /* Hide the | portion of >| when showing last page rather than end of results. */ } .screen-illust-container .page-change-indicator[data-side=left] { margin-left: 20px; left: 0; } .screen-illust-container .page-change-indicator[data-side=right] { margin-right: 20px; right: 0; } .screen-illust-container .page-change-indicator[data-side=right] svg { transform-origin: center center; transform: scale(-1, 1); } .screen-illust-container .page-change-indicator[data-icon=last-page] svg .bar { display: none; } .screen-illust-container .page-change-indicator svg { opacity: 0; } .screen-illust-container .page-change-indicator.flash svg { animation: flash-page-change-opacity 400ms ease-out 1 forwards; } .screen-illust-container .page-change-indicator.flash svg .animated { animation: flash-page-change-part 300ms ease-out 1 forwards; } @keyframes flash-page-change-opacity { 0% { opacity: 1; } 40% { opacity: 1; } 80% { opacity: 0; } } @keyframes flash-page-change-part { 0% { transform: translate(0, 0px); } 20% { transform: translate(-4px, 0px); } 100% { transform: translate(0, 0px); } } .screen-illust-container .translation-status { position: absolute; left: 2rem; bottom: 2rem; pointer-events: none; transition: opacity 0.25s; opacity: 0; } html[data-loading-translation] .screen-illust-container .translation-status { opacity: 1; } .screen-illust-container .translation-status .translation-contents { display: flex; align-items: center; justify-content: center; padding: 0.5rem; font-size: 2rem; background-color: #4040a0; border-radius: 100%; } .link-tab-popup .explanation { max-width: 25em; width: 100%; text-align: center; margin: 0 auto; } .link-tab-popup .button { display: inline-block; cursor: pointer; background-color: #000; padding: 0.5em 1em; margin: 0.5em; border-radius: 5px; } .link-tab-popup .content { width: 400px; padding: 1em; } .link-tab-popup .buttons { display: flex; } .link-tab-popup .tutorial-monitor { width: 290px; height: 125px; margin-bottom: -20px; } .link-tab-popup .tutorial-monitor .rotating-monitor { transform-origin: 75px 30px; animation: rotate-monitor 4500ms linear infinite; } @keyframes rotate-monitor { 0% { transform: rotate(0deg); } 10% { transform: rotate(90deg); } 50% { transform: rotate(90deg); } 60% { transform: rotate(0deg); } } .years-ago { padding: 0.25em; margin: 0.25em; white-space: nowrap; /* These links are mostly the same as box-link, but since the * menu background is the same as the box-link background color, * shift it a little to make it clear these are buttons. */ } .years-ago > a { padding: 4px 10px; background-color: #444; } .tree { user-select: none; -webkit-user-select: none; overflow-x: hidden; overflow-y: auto; flex: 1; } .tree .tree-item { position: relative; contain-intrinsic-height: 32px; } .tree .tree-item.allow-content-visibility { content-visibility: auto; } .tree .tree-item:not(.root-item) > .items { margin-left: 1em; } .tree .tree-item.selected > .self > .label { background-color: #003088; } .tree .tree-item > .self { display: flex; flex-direction: row; align-items: center; height: 2em; } .tree .tree-item > .self:focus { outline: none; } .tree .tree-item > .self > .label { padding: 0.5em; white-space: nowrap; } .tree .tree-item > .self.root-item { display: none; } .tree .tree-item > .self > .expander { display: flex; justify-content: center; align-items: center; font-size: 50%; width: 3em; height: 100%; } .tree .tree-item > .self > .expander > .expander-button { display: none; width: 3em; text-align: center; vertical-align: middle; } .tree .tree-item > .self > .expander[data-mode=loading] > .loading { display: block; } .tree .tree-item > .self > .expander[data-mode=none] > .none { display: block; } .tree .tree-item > .self > .expander[data-pending=true] > .expand { opacity: 0.5; } .tree .tree-item > .self > .expander[data-mode=expandable] > .expand, .tree .tree-item > .self > .expander[data-mode=expanded] > .expand { display: block; } .tree .tree-item > .self > .expander .expand { transform: rotate(0deg); transition: transform 0.25s; } .tree .tree-item > .self > .expander[data-mode=expanded] > .expand { transform: rotate(90deg); } .screen-search-container { --navigation-box-width: 25%; --navigation-box-reserved-width: var(--navigation-box-width); height: 100%; } .screen-search-container:not([data-show-navigation]) { --navigation-box-reserved-width: 0%; } .local-navigation-box { height: 100vh; width: var(--navigation-box-width); position: fixed; top: 0; left: 0; background-color: #111; border-right: solid 1px #444; padding-top: 0.5em; padding-left: 0.5em; opacity: 1; transition: opacity 0.35s, transform 0.35s; display: flex; flex-direction: column; } .screen-search-container:not([data-show-navigation]) .local-navigation-box { opacity: 0; pointer-events: none; transform: translate(-50%, 0); } .tree-popup { background-color: #222; color: #fff; position: fixed; pointer-events: none; outline-style: dotted; outline-width: 1px; outline-color: #aaa; } .tree-popup > .label { white-space: nowrap; } .thumb-popup { position: fixed; pointer-events: none; margin-left: 10px; width: 25%; height: 40%; max-height: 400px; max-width: 400px; } .thumb-popup > img { object-fit: contain; width: 100%; height: 100%; } .settings-dialog { position: fixed; z-index: 1000; inset: 0; overscroll-behavior: contain; --dialog-visible: 1; display: flex; align-items: flex-end; justify-content: center; --dialog-bg-color: rgba(0,0,0, var(--dialog-bg-alpha)); background-color: rgba(0, 0, 0, min(var(--dialog-backdrop-alpha), max(0, (var(--dialog-visible) - 0) * (var(--dialog-backdrop-alpha) - 0) / 1 + 0))); --edge-padding-inner-horiz: 20px; --edge-padding-inner-vert: 20px; --edge-padding-outer-horiz: 40px; --edge-padding-outer-vert: 20px; padding: var(--dialog-outer-padding-top) var(--dialog-outer-padding-right) var(--dialog-outer-padding-bottom) var(--dialog-outer-padding-left); font-size: 1.15rem; } html.ios .settings-dialog { inset: -1px 0; } .settings-dialog.floating { align-items: center; } .settings-dialog:not(.floating) { --dialog-bg-alpha: 0.5; --dialog-backdrop-alpha: 0.5; } .settings-dialog.floating { --dialog-bg-alpha: 0.75; --dialog-backdrop-alpha: 0.5; } html.mobile .settings-dialog { font-size: 1.2rem; } html.mobile .settings-dialog { --edge-padding-inner-horiz: 10px; --edge-padding-inner-vert: 10px; } .settings-dialog.floating { --dialog-outer-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-outer-horiz))); --dialog-outer-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-outer-horiz))); --dialog-outer-padding-top: calc(max(env(safe-area-inset-top), var(--edge-padding-outer-vert))); --dialog-outer-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-outer-vert))); --dialog-inner-padding-left: var(--edge-padding-inner-horiz); --dialog-inner-padding-right: var(--edge-padding-inner-horiz); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: var(--edge-padding-inner-vert); } .settings-dialog.floating .dialog { opacity: min(1, max(0, (var(--dialog-visible) - 0) * 1 / 1 + 0)); } .settings-dialog:not(.floating) { --edge-padding-horiz: 20px; --edge-padding-vert: 20px; --dialog-extra-top-padding: 2vh; --dialog-outer-padding-left: 0px; --dialog-outer-padding-right: 0px; --dialog-outer-padding-top: calc(env(safe-area-inset-top) + var(--dialog-extra-top-padding)); --dialog-outer-padding-bottom: 0px; --dialog-inner-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-inner-horiz))); --dialog-inner-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-inner-horiz))); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-inner-vert))); } .settings-dialog:not(.floating) .dialog { border-radius: 25px 25px 0 0; overflow: hidden; } .settings-dialog > .dialog { padding: var(--dialog-inner-padding-top) var(--dialog-inner-padding-right) var(--dialog-inner-padding-bottom) var(--dialog-inner-padding-left); transform: translateY(calc((var(--dialog-visible) - 0) * -100% / 1 + 100%)); } .settings-dialog > .dialog .scroll { overflow-x: hidden; overflow-y: auto; overflow-y: overlay; touch-action: pan-y; margin-right: calc(var(--dialog-inner-padding-right) * -1); padding-right: calc(var(--dialog-inner-padding-right)); margin-left: calc(var(--dialog-inner-padding-left) * -1); padding-left: calc(var(--dialog-inner-padding-left)); margin-bottom: calc(var(--dialog-inner-padding-bottom) * -1); padding-bottom: calc(var(--dialog-inner-padding-bottom)); } .settings-dialog.floating .dialog { min-width: 20em; max-width: min(45em, 90vw); max-height: 90vh; border-radius: 5px; } .settings-dialog.small .dialog { min-width: 10em; max-width: 45em; } .settings-dialog:not(.floating) .dialog { width: 100%; } .settings-dialog .dialog { max-height: 100%; background-color: var(--dialog-bg-color); color: var(--ui-fg-color); position: relative; display: flex; flex-direction: column; } .settings-dialog.dragging-dialog > .dialog { pointer-events: none; } .settings-dialog.dragging-dialog .vertical-scroller { overflow: hidden !important; } .settings-dialog .header { --icon-size: 2; line-height: 2em; display: flex; align-items: center; } .settings-dialog .header .close-button-container { flex: 1; } .settings-dialog .header .close-button-container .close-button { --icon-size: 1.5; color: var(--button-color); cursor: pointer; justify-self: center; } @media (hover: hover) { .settings-dialog .header .close-button-container .close-button:hover { color: var(--button-highlight-color); } } .settings-dialog .header .close-button-container .close-button > svg { display: block; } .settings-dialog .header .header-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; } .settings-dialog .header .center-header-helper { width: calc(1em * var(--icon-size) + 1em); flex: 1; flex-shrink: 100000; } .settings-dialog.floating .dialog { min-height: min(30em, 100%); width: 100%; } .settings-dialog.phone .scroll { justify-content: center; } .settings-dialog .sections { white-space: nowrap; display: flex; flex-direction: column; overflow-y: auto; } .settings-dialog .sections > .box-link { padding: 0.5em; cursor: pointer; } .settings-dialog .sections > .box-link:not(.selected) { opacity: 0.65; } .settings-dialog .sections > .box-link:not(.active) { background: none; } @media (hover: hover) { .settings-dialog .sections > .box-link:hover { background-color: var(--box-link-hover-color); } } html[data-whats-new-updated] .settings-dialog .sections .settings-page-button[data-page=whatsNew] { color: #ff0; } .settings-dialog > .dialog > .scroll > .items { flex: 1; overflow-y: auto; } .settings-dialog .settings-list { display: flex; flex-direction: column; height: 100%; } .settings-dialog .settings-list .settings-row { padding: 0.5em; } .settings-dialog .settings-list .box-link { height: auto; align-items: stretch; flex-direction: column; --box-link-bg-color: #00000000; } .settings-dialog .settings-list .box-link > .buttons > .box-link { padding: 0.35em 0.75em; } .settings-dialog .settings-list .box-link:not(.clickable):hover { cursor: inherit; background: none; } .settings-dialog .settings-list > .box-link > .label-box .label { flex: 1; } .settings-dialog .settings-list > .box-link > .explanation { font-size: 80%; color: #ccc; } html.mobile .settings-dialog .sections { gap: 0.5em; } html.mobile .settings-dialog:not(.floating) .sections { flex: 1; } .settings-dialog > .dialog > .scroll { display: flex; flex-direction: row; gap: 2em; } .settings-dialog-page { position: fixed; z-index: 1000; inset: 0; overscroll-behavior: contain; --dialog-visible: 1; display: flex; align-items: flex-end; justify-content: center; --dialog-bg-color: rgba(0,0,0, var(--dialog-bg-alpha)); background-color: rgba(0, 0, 0, min(var(--dialog-backdrop-alpha), max(0, (var(--dialog-visible) - 0) * (var(--dialog-backdrop-alpha) - 0) / 1 + 0))); --edge-padding-inner-horiz: 20px; --edge-padding-inner-vert: 20px; --edge-padding-outer-horiz: 40px; --edge-padding-outer-vert: 20px; padding: var(--dialog-outer-padding-top) var(--dialog-outer-padding-right) var(--dialog-outer-padding-bottom) var(--dialog-outer-padding-left); font-size: 1.15rem; } html.ios .settings-dialog-page { inset: -1px 0; } .settings-dialog-page.floating { align-items: center; } .settings-dialog-page:not(.floating) { --dialog-bg-alpha: 0.5; --dialog-backdrop-alpha: 0.5; } .settings-dialog-page.floating { --dialog-bg-alpha: 0.75; --dialog-backdrop-alpha: 0.5; } html.mobile .settings-dialog-page { font-size: 1.2rem; } html.mobile .settings-dialog-page { --edge-padding-inner-horiz: 10px; --edge-padding-inner-vert: 10px; } .settings-dialog-page.floating { --dialog-outer-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-outer-horiz))); --dialog-outer-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-outer-horiz))); --dialog-outer-padding-top: calc(max(env(safe-area-inset-top), var(--edge-padding-outer-vert))); --dialog-outer-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-outer-vert))); --dialog-inner-padding-left: var(--edge-padding-inner-horiz); --dialog-inner-padding-right: var(--edge-padding-inner-horiz); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: var(--edge-padding-inner-vert); } .settings-dialog-page.floating .dialog { opacity: min(1, max(0, (var(--dialog-visible) - 0) * 1 / 1 + 0)); } .settings-dialog-page:not(.floating) { --edge-padding-horiz: 20px; --edge-padding-vert: 20px; --dialog-extra-top-padding: 2vh; --dialog-outer-padding-left: 0px; --dialog-outer-padding-right: 0px; --dialog-outer-padding-top: calc(env(safe-area-inset-top) + var(--dialog-extra-top-padding)); --dialog-outer-padding-bottom: 0px; --dialog-inner-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-inner-horiz))); --dialog-inner-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-inner-horiz))); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-inner-vert))); } .settings-dialog-page:not(.floating) .dialog { border-radius: 25px 25px 0 0; overflow: hidden; } .settings-dialog-page > .dialog { padding: var(--dialog-inner-padding-top) var(--dialog-inner-padding-right) var(--dialog-inner-padding-bottom) var(--dialog-inner-padding-left); transform: translateY(calc((var(--dialog-visible) - 0) * -100% / 1 + 100%)); } .settings-dialog-page > .dialog .scroll { overflow-x: hidden; overflow-y: auto; overflow-y: overlay; touch-action: pan-y; margin-right: calc(var(--dialog-inner-padding-right) * -1); padding-right: calc(var(--dialog-inner-padding-right)); margin-left: calc(var(--dialog-inner-padding-left) * -1); padding-left: calc(var(--dialog-inner-padding-left)); margin-bottom: calc(var(--dialog-inner-padding-bottom) * -1); padding-bottom: calc(var(--dialog-inner-padding-bottom)); } .settings-dialog-page.floating .dialog { min-width: 20em; max-width: min(45em, 90vw); max-height: 90vh; border-radius: 5px; } .settings-dialog-page.small .dialog { min-width: 10em; max-width: 45em; } .settings-dialog-page:not(.floating) .dialog { width: 100%; } .settings-dialog-page .dialog { max-height: 100%; background-color: var(--dialog-bg-color); color: var(--ui-fg-color); position: relative; display: flex; flex-direction: column; } .settings-dialog-page.dragging-dialog > .dialog { pointer-events: none; } .settings-dialog-page.dragging-dialog .vertical-scroller { overflow: hidden !important; } .settings-dialog-page .header { --icon-size: 2; line-height: 2em; display: flex; align-items: center; } .settings-dialog-page .header .close-button-container { flex: 1; } .settings-dialog-page .header .close-button-container .close-button { --icon-size: 1.5; color: var(--button-color); cursor: pointer; justify-self: center; } @media (hover: hover) { .settings-dialog-page .header .close-button-container .close-button:hover { color: var(--button-highlight-color); } } .settings-dialog-page .header .close-button-container .close-button > svg { display: block; } .settings-dialog-page .header .header-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; } .settings-dialog-page .header .center-header-helper { width: calc(1em * var(--icon-size) + 1em); flex: 1; flex-shrink: 100000; } .settings-dialog-page.floating .dialog { min-height: min(30em, 100%); width: 100%; } .settings-dialog-page.phone .scroll { justify-content: center; } .settings-dialog-page .sections { white-space: nowrap; display: flex; flex-direction: column; overflow-y: auto; } .settings-dialog-page .sections > .box-link { padding: 0.5em; cursor: pointer; } .settings-dialog-page .sections > .box-link:not(.selected) { opacity: 0.65; } .settings-dialog-page .sections > .box-link:not(.active) { background: none; } @media (hover: hover) { .settings-dialog-page .sections > .box-link:hover { background-color: var(--box-link-hover-color); } } html[data-whats-new-updated] .settings-dialog-page .sections .settings-page-button[data-page=whatsNew] { color: #ff0; } .settings-dialog-page > .dialog > .scroll > .items { flex: 1; overflow-y: auto; } .settings-dialog-page .settings-list { display: flex; flex-direction: column; height: 100%; } .settings-dialog-page .settings-list .settings-row { padding: 0.5em; } .settings-dialog-page .settings-list .box-link { height: auto; align-items: stretch; flex-direction: column; --box-link-bg-color: #00000000; } .settings-dialog-page .settings-list .box-link > .buttons > .box-link { padding: 0.35em 0.75em; } .settings-dialog-page .settings-list .box-link:not(.clickable):hover { cursor: inherit; background: none; } .settings-dialog-page .settings-list > .box-link > .label-box .label { flex: 1; } .settings-dialog-page .settings-list > .box-link > .explanation { font-size: 80%; color: #ccc; } .edit-search-dialog { position: fixed; z-index: 1000; inset: 0; overscroll-behavior: contain; --dialog-visible: 1; display: flex; align-items: flex-end; justify-content: center; --dialog-bg-color: rgba(0,0,0, var(--dialog-bg-alpha)); background-color: rgba(0, 0, 0, min(var(--dialog-backdrop-alpha), max(0, (var(--dialog-visible) - 0) * (var(--dialog-backdrop-alpha) - 0) / 1 + 0))); --edge-padding-inner-horiz: 20px; --edge-padding-inner-vert: 20px; --edge-padding-outer-horiz: 40px; --edge-padding-outer-vert: 20px; padding: var(--dialog-outer-padding-top) var(--dialog-outer-padding-right) var(--dialog-outer-padding-bottom) var(--dialog-outer-padding-left); } html.ios .edit-search-dialog { inset: -1px 0; } .edit-search-dialog.floating { align-items: center; } .edit-search-dialog:not(.floating) { --dialog-bg-alpha: 0.5; --dialog-backdrop-alpha: 0.5; } .edit-search-dialog.floating { --dialog-bg-alpha: 0.75; --dialog-backdrop-alpha: 0.5; } html.mobile .edit-search-dialog { font-size: 1.2rem; } html.mobile .edit-search-dialog { --edge-padding-inner-horiz: 10px; --edge-padding-inner-vert: 10px; } .edit-search-dialog.floating { --dialog-outer-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-outer-horiz))); --dialog-outer-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-outer-horiz))); --dialog-outer-padding-top: calc(max(env(safe-area-inset-top), var(--edge-padding-outer-vert))); --dialog-outer-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-outer-vert))); --dialog-inner-padding-left: var(--edge-padding-inner-horiz); --dialog-inner-padding-right: var(--edge-padding-inner-horiz); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: var(--edge-padding-inner-vert); } .edit-search-dialog.floating .dialog { opacity: min(1, max(0, (var(--dialog-visible) - 0) * 1 / 1 + 0)); } .edit-search-dialog:not(.floating) { --edge-padding-horiz: 20px; --edge-padding-vert: 20px; --dialog-extra-top-padding: 2vh; --dialog-outer-padding-left: 0px; --dialog-outer-padding-right: 0px; --dialog-outer-padding-top: calc(env(safe-area-inset-top) + var(--dialog-extra-top-padding)); --dialog-outer-padding-bottom: 0px; --dialog-inner-padding-left: calc(max(env(safe-area-inset-left), var(--edge-padding-inner-horiz))); --dialog-inner-padding-right: calc(max(env(safe-area-inset-right), var(--edge-padding-inner-horiz))); --dialog-inner-padding-top: var(--edge-padding-inner-vert); --dialog-inner-padding-bottom: calc(max(var(--fixed-safe-area-inset-bottom), var(--edge-padding-inner-vert))); } .edit-search-dialog:not(.floating) .dialog { border-radius: 25px 25px 0 0; overflow: hidden; } .edit-search-dialog > .dialog { padding: var(--dialog-inner-padding-top) var(--dialog-inner-padding-right) var(--dialog-inner-padding-bottom) var(--dialog-inner-padding-left); transform: translateY(calc((var(--dialog-visible) - 0) * -100% / 1 + 100%)); } .edit-search-dialog > .dialog .scroll { overflow-x: hidden; overflow-y: auto; overflow-y: overlay; touch-action: pan-y; margin-right: calc(var(--dialog-inner-padding-right) * -1); padding-right: calc(var(--dialog-inner-padding-right)); margin-left: calc(var(--dialog-inner-padding-left) * -1); padding-left: calc(var(--dialog-inner-padding-left)); margin-bottom: calc(var(--dialog-inner-padding-bottom) * -1); padding-bottom: calc(var(--dialog-inner-padding-bottom)); } .edit-search-dialog.floating .dialog { min-width: 20em; max-width: min(45em, 90vw); max-height: 90vh; border-radius: 5px; } .edit-search-dialog.small .dialog { min-width: 10em; max-width: 45em; } .edit-search-dialog:not(.floating) .dialog { width: 100%; } .edit-search-dialog .dialog { max-height: 100%; background-color: var(--dialog-bg-color); color: var(--ui-fg-color); position: relative; display: flex; flex-direction: column; } .edit-search-dialog.dragging-dialog > .dialog { pointer-events: none; } .edit-search-dialog.dragging-dialog .vertical-scroller { overflow: hidden !important; } .edit-search-dialog .header { --icon-size: 2; line-height: 2em; display: flex; align-items: center; } .edit-search-dialog .header .close-button-container { flex: 1; } .edit-search-dialog .header .close-button-container .close-button { --icon-size: 1.5; color: var(--button-color); cursor: pointer; justify-self: center; } @media (hover: hover) { .edit-search-dialog .header .close-button-container .close-button:hover { color: var(--button-highlight-color); } } .edit-search-dialog .header .close-button-container .close-button > svg { display: block; } .edit-search-dialog .header .header-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; } .edit-search-dialog .header .center-header-helper { width: calc(1em * var(--icon-size) + 1em); flex: 1; flex-shrink: 100000; } .edit-search-dialog .scroll { display: flex; flex-direction: column; height: 100%; } .edit-search-dialog .scroll .navigation-button { font-size: 1.25em; --box-link-bg-color: none; overflow: hidden; } .muted-tags-popup { padding: 0.5em 1em; display: flex; flex-direction: column; gap: 0.5em; overflow-y: auto; } .edit-post-mute-dialog .mute-warning, .muted-tags-popup .mute-warning { border: solid 2px black; border-radius: 15px; background-color: #000; padding: 1em; } .edit-post-mute-dialog .add-muted-user-box .font-icon, .muted-tags-popup .add-muted-user-box .font-icon { font-size: 24px; vertical-align: middle; } .edit-post-mute-dialog .non-premium-mute-warning, .muted-tags-popup .non-premium-mute-warning { margin-right: 40px; } .edit-post-mute-dialog .non-premium-mute-warning .icon, .muted-tags-popup .non-premium-mute-warning .icon { font-size: 24px; color: #ffff00; } .edit-post-mute-dialog .post-mute-list, .muted-tags-popup .post-mute-list { display: flex; flex-direction: column; gap: 4px; } .edit-post-mute-dialog .post-mute-list .entry, .muted-tags-popup .post-mute-list .entry { display: flex; align-items: center; gap: 0.5em; } .edit-post-mute-dialog .post-mute-list .entry.muted .tag-name, .muted-tags-popup .post-mute-list .entry.muted .tag-name { color: #ffaaaa; } .edit-post-mute-dialog .mute-list .remove-mute .font-icon, .muted-tags-popup .mute-list .remove-mute .font-icon { font-size: 24px; vertical-align: middle; } .image-editor { width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none; } .image-editor.temporarily-hidden { display: none; pointer-events: none; } .image-editor .save-edits.dirty { color: #0f0; } .image-editor .spinner .icon { animation: spin 1000ms linear infinite forwards; } .image-editor .image-editor-buttons { position: absolute; display: grid; grid-template-columns: 1fr auto 1fr; font-size: 150%; width: 100%; align-items: flex-start; } body:not(.focused) .image-editor .image-editor-buttons.top { display: none; } .image-editor .image-editor-buttons.top { top: 0.5em; } .image-editor .image-editor-buttons.bottom { bottom: 0.5em; } .image-editor .image-editor-buttons > .left { margin-right: auto; } .image-editor .image-editor-buttons > .center { grid-column-start: 2; } .image-editor .image-editor-buttons > .right { margin-left: auto; } .image-editor .image-editor-buttons .image-editor-button-row { pointer-events: auto; } .image-editor .image-editor-buttons .block-button .font-icon { display: block; } @keyframes spin { 0% { transform: rotate(0); } 100% { transform: rotate(360deg); } } .crop-editor-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } .crop-editor-overlay .crop-box { position: relative; --overlap: 1vh; } .crop-editor-overlay .crop-box [data-crop=all] { box-shadow: 0px 0px 0px 10000px rgba(0, 0, 0, 0.5019607843); } .crop-editor-overlay .crop-box[data-mode=crop] [data-crop=all] { outline: 3px solid #fff; outline-style: ridge; } .crop-editor-overlay .crop-box[data-mode=safe_zone] [data-crop=all] { outline: 1px solid #fff; outline-offset: 1px; pointer-events: none; } .crop-editor-overlay .crop-box .handle { position: absolute; } .crop-editor-overlay .crop-box .handle[data-crop=top] { cursor: n-resize !important; } .crop-editor-overlay .crop-box .handle[data-crop=left] { cursor: w-resize !important; } .crop-editor-overlay .crop-box .handle[data-crop=right] { cursor: e-resize !important; } .crop-editor-overlay .crop-box .handle[data-crop=bottom] { cursor: s-resize !important; } .crop-editor-overlay .crop-box .handle[data-crop=topleft] { cursor: nw-resize !important; } .crop-editor-overlay .crop-box .handle[data-crop=topright] { cursor: ne-resize !important; } .crop-editor-overlay .crop-box .handle[data-crop=bottomleft] { cursor: sw-resize !important; } .crop-editor-overlay .crop-box .handle[data-crop=bottomright] { cursor: se-resize !important; } .crop-editor-overlay .crop-box .handle[data-crop=all] { cursor: move !important; } .crop-editor-overlay .crop-box .handle[data-crop=top] { width: 100%; height: 10000px; bottom: calc(100% - var(--overlap)); } .crop-editor-overlay .crop-box .handle[data-crop=left] { width: 10000px; height: 100%; right: calc(100% - var(--overlap)); } .crop-editor-overlay .crop-box .handle[data-crop=right] { width: 10000px; height: 100%; left: calc(100% - var(--overlap)); } .crop-editor-overlay .crop-box .handle[data-crop=bottom] { width: 100%; height: 10000px; top: calc(100% - var(--overlap)); } .crop-editor-overlay .crop-box .handle[data-crop=topleft] { width: 10000px; height: 10000px; right: calc(100% - var(--overlap)); bottom: calc(100% - var(--overlap)); } .crop-editor-overlay .crop-box .handle[data-crop=topright] { width: 10000px; height: 10000px; bottom: calc(100% - var(--overlap)); left: calc(100% - var(--overlap)); } .crop-editor-overlay .crop-box .handle[data-crop=bottomleft] { width: 10000px; height: 10000px; top: calc(100% - var(--overlap)); right: calc(100% - var(--overlap)); } .crop-editor-overlay .crop-box .handle[data-crop=bottomright] { width: 10000px; height: 10000px; top: calc(100% - var(--overlap)); left: calc(100% - var(--overlap)); } .crop-editor-overlay .crop-box .handle[data-crop=all] { width: 100%; height: 100%; left: 0; } .inpaint-editor-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } .inpaint-editor-overlay.creating-lines { cursor: crosshair !important; } .inpaint-editor-overlay .inpaint-segment { pointer-events: auto; } .inpaint-editor-overlay .inpaint-segment .inpaint-line { fill: none; stroke: #f00; stroke-linecap: round; stroke-linejoin: round; stroke-opacity: 0.75; mix-blend-mode: difference; } .inpaint-editor-overlay .inpaint-segment:hover { pointer-events: all; } .inpaint-editor-overlay .inpaint-segment:hover .inpaint-handle { stroke: #000; } .inpaint-editor-overlay .inpaint-segment .inpaint-handle { opacity: 0; } .inpaint-editor-overlay .inpaint-segment.selected .inpaint-handle, .inpaint-editor-overlay .inpaint-segment:hover .inpaint-handle { opacity: 1; } .inpaint-editor-overlay .inpaint-segment .inpaint-handle { fill: none; opacity: 0.25; stroke: #000; pointer-events: all; } .pan-editor-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } .pan-editor-overlay .handle { overflow: visible; } .pan-editor-overlay .pan-editor-crop-region { width: 100%; height: 100%; position: relative; } .pan-editor-overlay .monitor-preview-box { position: absolute; top: 0; left: 0; width: 100%; height: 100%; transform-origin: 0 0; } .pan-editor-overlay .monitor-preview-box > .box { box-shadow: 0px 0px 0px 100000px rgba(0, 0, 0, 0.5019607843); outline: 1px dashed #fff; width: 100%; height: 100%; } .ranking-data-source .date-row .nav-today { display: inline-flex; justify-content: center; margin: 0 0.25em; min-width: 5em; } .tag-search-with-related-tags { display: flex; align-items: center; flex-wrap: wrap; } .tag-search-with-related-tags .search-box { flex: 1; } .slider { display: flex; align-items: center; position: relative; min-width: 4em; cursor: pointer; --fill: 75%; --track-height: 8px; --thumb-height: 24px; --on-color: #07F; --off-color: #fff; min-height: var(--thumb-height); margin: 0 calc(var(--thumb-height) / 2); } @media (hover: hover) { .slider:hover { --on-color: #0AF; } } .slider:active { --on-color: #0AF; } .slider .track-left { width: var(--fill); height: var(--track-height); border-radius: 2px 0 0 2px; background-color: var(--on-color); } .slider .track-right { flex: 1; height: var(--track-height); border-radius: 0 2px 2px 0; background-color: var(--off-color); } .slider .thumb { height: var(--thumb-height); aspect-ratio: 1; background-color: var(--on-color); position: absolute; left: var(--fill); translate: -50% 0; border-radius: 100%; } .menu-dropdown-button { --box-link-hover-color: transparent; --box-link-selected-color: transparent; --box-link-selected-hover-color: transparent; } .menu-slider .slider { flex: 1; } /*# sourceMappingURL=data:application/json;base64,ewoidmVyc2lvbiI6IDMsCiJzb3VyY2VSb290IjogImh0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9wcGl4aXYvcHBpeGl2L3IyMzEiLAoic291cmNlcyI6IFsKInJlc291cmNlcy9tYWluLnNjc3MiCl0sCiJuYW1lcyI6IFtdLAoibWFwcGluZ3MiOiAiQUE4QkE7RUFDSTtFQUdBO0VBR0E7RUFJQTtFQUNBOzs7QUFHSjtFQUNJO0VBS0E7RUFTQTtFQUNBO0VBRUE7RUFDQTtFQU1BO0VBS0E7RUFJQTtFQWtCQTtFQUFzQjtFQUFxQjtFQUFzQjtFQW1CakU7O0FBaEVBO0VBS0k7O0FBdUJKO0VBQ0k7O0FBa0JBO0VBQTRCOztBQUM1QjtFQUE0Qjs7QUFDNUI7RUFBNEI7O0FBQzVCO0VBQTRCOztBQUdoQztFQUNJOztBQVdKO0VBR0k7OztBQTZDQTtFQUNJO0VBQ0E7O0FBSVI7RUFDSTtFQUNBOztBQUNBO0VBQVU7O0FBR2Q7RUFDSTtFQUNBO0VBR0E7RUFDQTs7QUFIQTtFQUFVOztBQU1kO0VBQW9COzs7QUFHeEI7RUFDSTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7OztBQUdKO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7O0FBTUo7RUFDSTtFQUNBO0VBQ0E7RUFFQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7OztBQUdKO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7OztBQUdKO0VBQ0k7RUFDQTs7O0FBS0E7RUFDSTs7QUFJQTtFQUNJOzs7QUFTUjtFQUNJO0VBQ0E7O0FBSUo7RUFBZTs7QUFJZjtFQUVJOzs7QUFLUjtBQUNBO0VBR0k7RUFDQTtFQWVBO0VBQ0E7RUFFQTtFQUNBO0VBQ0E7RUFHQTtFQUNBO0FBRUE7RUFDQTtFQUNBO0VBQ0E7RUFDQTtBQUVBO0VBQ0E7RUFDQTtFQUNBO0VBRUE7QUFFQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtBQUVBO0FBQUE7RUFFQTtFQUNBO0VBRUE7RUFDQTtFQUVBO0VBQ0E7RUFDQTs7QUF4REE7RUFDSTtFQUNBO0VBSUE7RUFDQTs7QUFHSjtFQUF3Qzs7QUFDaEM7RUFBNEI7O0FBQ3hCO0VBQWdDOzs7QUErQ2hEO0VBQ0k7OztBQUdKO0VBQ0k7OztBQUdKO0VBRUk7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBQ0E7RUFBaUI7OztBQUdyQjtBQUFBO0FBRUE7RUFDSTs7O0FBR0o7RUFFSTs7QUFFQTtFQUNJO0VBQ0E7RUFDQTtFQUNBOztBQUVBO0VBQ0k7RUFDQTtFQUNBOztBQU9SO0VBQ0k7O0FBS0E7RUFDSTs7QUFRUjtFQUNJOztBQUdKO0VBRUk7RUFDQTtFQUNBOztBQUlBO0VBQXFCOztBQUd6QjtFQUVJOzs7QUFLSjtFQUNJO0VBQ0E7RUFDQTs7QUFHSjtFQUVJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFFQTtFQUNJOztBQUVBO0VBRUk7RUFDQTtFQUNBOztBQUdKO0VBQ0k7OztBQU9oQjtFQUNJOzs7QUFJSjtFQUVJO0VBQ0E7RUFPQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBRUE7O0FBVkE7RUFDSTs7QUFXSjtFQUNJOztBQUtBO0VBS0k7O0FBRUE7RUFFSTs7QUFPWjtFQUVJOztBQUVBO0VBRUk7O0FBR0o7RUFDSTs7QUFJUjtFQUNJOztBQUlBO0VBQXlCOztBQUd6QjtFQUNJOztBQVFKO0VBRUk7RUFDQTtFQUNBOztBQUdKO0VBSUk7O0FBR0E7RUFDSTs7QUFLUjtFQUNJOztBQUlSO0VBRUk7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBR0E7O0FBRUE7RUFDSTs7QUFHSjtFQUNJOztBQUdKO0VBQ0k7RUFDQTs7QUFHSjtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFFQTtFQUNJO0VBQ0E7OztBQU1oQjtFQUNJO0VBQ0E7RUFDQTtFQUNBOztBQUVBO0VBQ0k7RUFDQTs7QUFFQTtFQUVJO0VBQ0E7RUFDQTtFQUNBOztBQUdKO0VBQTRCOztBQUM1QjtFQUE4Qjs7QUFDOUI7RUFDSTtFQUNBOzs7QUFLWjtFQUNJO0VBQ0E7RUFDQTs7O0FBSUo7RUFDSTs7O0FBNkJKO0VBdkJJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBaUJBO0VBQ0E7RUFDQTtFQUlBO0VBRUE7RUFDQTtFQUVBO0VBQ0E7O0FBM0JBO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBZUo7RUFBb0M7O0FBTXBDO0VBQ0k7OztBQUtKO0VBQ0k7O0FBaUJKO0VBQ0k7RUFDQTtBQUVBO0FBQUE7RUFFQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBRUE7RUFDSTs7QUFHSjtFQUNJO0VBQ0E7RUFDQTs7QUFJUjtFQUVJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUVBO0VBQ0E7RUFDQTtFQUNBO0VBRUE7RUFFQTtFQUNBO0VBQ0E7RUFFQTtFQUNBOztBQUdBO0VBRUk7RUFDQTtFQUNBO0VBQ0E7O0FBS0o7RUFFSTtFQUNBO0VBQ0E7RUFDQTs7QUFHSjtFQUVJO0VBQ0E7RUFHQTs7QUFHSjtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBR0E7RUFDQTs7QUFHSjtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBR0o7RUFDSTs7QUFHSjtBQUFBO0VBR0k7RUFDQTs7QUFHSjtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7OztBQU1SO0VBQ0k7RUFDQTtFQUNBOztBQUdKO0VBUEo7SUFTUTtJQUNBOztFQUVBO0lBQ0k7O0VBQ0E7SUFDSTtJQUNBO0lBQ0E7Ozs7QUFNaEI7RUFDSTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUVBO0VBQ0k7OztBQUlSO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUtBO0VBQ0E7RUFDQTs7QUFFQTtFQUNJO0VBQ0E7O0FBSUo7RUFDSTtFQUNBO0VBQ0E7O0FBR0o7RUFDSTtJQUVJOzs7QUFJUjtFQUNJOztBQUlKO0VBQ0k7O0FBR0o7RUFDSTs7O0FBSVI7RUFDSTtJQUNJOzs7QUFJUjtFQUNJO0VBR0E7O0FBRUE7RUFDSTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUVBO0VBQ0k7SUFDSTs7O0FBS1o7RUFDSTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBRUE7RUFDSTtFQUNBO0VBQ0E7RUFDQTtBQUVBO0FBQUE7RUFFQTs7QUFHSjtFQUNJO0VBQ0E7OztBQUtaO0VBSUk7O0FBRUE7RUFDSTtFQUNBO0VBQ0E7RUFDQTs7QUFHSjtFQUNJOzs7QUFJUjtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFFQTtFQUNJO0lBQ0k7SUFDQTs7OztBQUtaO0FBQ0E7QUFBQTtFQUVJO0VBQ0E7OztBQUdKO0FBQ0E7RUFDSTs7O0FBR0o7RUFDSTs7QUFFQTtFQUNJO0lBQ0k7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBOztFQUVKO0lBQTBDOztFQUMxQztJQUMwQzs7RUFFMUM7SUFDSTtJQUNBOzs7O0FBS1o7RUFBbUM7OztBQUNuQztFQUFpQzs7O0FBQ2pDO0VBQStCOzs7QUFDL0I7RUFBcUI7OztBQUNyQjtFQUF1Qjs7O0FBR3ZCO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOzs7QUFJSjtFQUNJO0VBQ0E7RUFDQTs7O0FBR0o7RUFDSTs7O0FBRUo7RUFDSTs7O0FBR0o7QUFDQTtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7O0FBR0o7RUFHSTtFQUdBOztBQUVBO0VBQ0k7RUFDQTtFQUVBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUE5N0JKO0VBQ0E7O0FBczhCSTtFQUNJO0VBQ0E7O0FBR0o7RUFDSTs7QUFHSjtFQUNJOztBQU1BO0VBQ0k7O0FBTVI7RUFDSTtFQUNBO0VBSUE7O0FBR0E7RUFBVTs7QUFFVjtFQUNJOztBQUdKO0VBQ0k7RUFDQTtFQUNBOztBQU9aO0VBdi9CQTtFQUNBO0VBK2dDSTtFQUNBO0VBQ0E7RUFDQTtFQUVBO0VBQ0E7RUFFQTtFQUNBO0VBQ0E7RUFHQTtFQUVBO0VBQ0E7O0FBdENBO0VBQ0k7RUFDQTs7QUFPSjtFQUNJO0VBQ0E7O0FBTUo7RUFDSTtFQUNBOztBQW9CSjtFQUtJOztBQUdKO0VBQ0k7O0FBR0o7RUFDSTtFQUNBO0VBQ0E7O0FBSVI7RUFFSTtFQUNBO0VBR0E7RUFDQTs7QUFHSjtFQUlJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFFQTtFQUVJOztBQUdBO0VBQ0k7O0FBSVI7RUFFSTs7QUFJSjtFQVVJOztBQUpBO0VBQ0k7O0FBT0o7RUFHSTs7QUFLWjtFQUNJO0VBQ0E7RUFHQTtFQUNBO0VBRUE7RUFDQTtFQUNBO0VBQ0E7O0FBR0k7RUFDSTtFQUNBO0VBQ0E7O0FBSVI7RUFDSTtFQUNBOztBQUVBO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUlSO0VBQ0k7O0FBSVI7RUFDSTs7O0FBSVI7RUFHSTtFQUNBO0VBQ0E7OztBQVFBO0VBQ0k7O0FBR0o7RUFDSTtFQUNBO0VBQ0E7RUFDQTs7QUFHSjtFQUNJO0lBQ0k7OztBQUlSO0VBbnNCQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQTRyQkk7O0FBMXJCSjtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQW9yQkE7RUFDSTs7QUFLUjtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBSUE7RUFHQTs7QUFHSjtFQUNJOztBQUdKO0VBQ0k7RUFDQTtFQUNBOztBQUtJO0VBQWU7O0FBQ2Y7RUFBYzs7QUFLdEI7RUFDSTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUdBO0FBV0E7QUFBQTs7QUFUQTtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFLSjtFQUNJO0VBQ0E7O0FBS1I7RUFDSTtFQUNBO0VBQ0E7O0FBR0o7RUFDSTtFQUNBO0VBQ0E7RUFDQTtBQVlBOztBQVZBO0VBQ0k7QUFFQTtBQUFBO0VBRUE7RUFDQTtFQUNBOztBQUlKO0VBQ0k7O0FBR0o7RUFDSTtFQUNBO0VBQ0E7O0FBRUE7RUFDSTtFQUNBOztBQU1SO0VBbENKO0lBc0NRO0lBWUE7O0VBWEE7SUFBcUQ7O0VBRXJEO0lBRUk7SUFDQTs7RUFPSjtJQUErQzs7O0FBR25EO0VBOEJJOztBQTdCQTtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFFQTtFQUNBO0VBQ0E7RUFDQTtFQUVBOztBQUVBO0VBRUk7RUFDQTtFQUNBOztBQUdKO0VBRUk7O0FBVVI7RUFDSTtFQUNBOztBQUdKO0VBQ0k7O0FBSVI7RUFDSTs7QUFHSjtFQUVJO0lBQ0k7OztBQUlSO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFLQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFHQTtFQUNJOztBQVdKO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUdKO0VBQ0k7RUFFQTtFQUNBOztBQUdKO0VBQ0k7RUFDQTtFQUdBO0VBRUE7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBRUE7RUFDSTtFQUNBOztBQUVBO0FBQ0k7QUFBQTtFQUVBOztBQUlSO0VBQ0k7O0FBSVI7RUFDSTtFQUNBOztBQUdKO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFJQTtFQUNJOztBQUdKO0VBQ0k7O0FBRUo7RUFDSTs7QUFFSjtFQUNJOztBQUdKO0VBQ0k7RUFDQTs7QUFHQTtFQUNJOztBQUtKO0VBQWE7O0FBQ2I7RUFDSTtFQUNBOztBQUlaO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7O0FBS1o7RUFFUTtBQUFBO0FBQUE7SUFJSTs7O0FBS1o7RUFDSTs7QUFHSjtFQUdJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBSUE7RUFDSTtFQUlBO0VBRUE7RUFDQTs7QUFFQTtFQUNJOztBQUdKO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7O0FBQ0E7RUFBVzs7QUFHWDtFQUEwQjs7QUFFMUI7RUFDSTs7O0FBV3BCO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7O0FBSUE7RUFDSTtFQUNBOzs7QUFLUjtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBR0E7RUFDSTs7QUFPSjtFQUNJOztBQUdKO0VBQ0k7RUFDQTs7QUFPQTtFQUNJOzs7QUFTWjtFQUlJO0VBRUE7RUFDQTs7QUFJQTtFQUFjO0VBQXFCOztBQUVuQztFQUVJO0VBQ0E7RUFDQTtFQUVBO0VBQ0E7OztBQUlSO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUlBO0VBRUE7RUFFQTtFQUNBO0VBQ0E7RUFFQTs7QUFFQTtFQUNJO0VBQ0E7RUFDQTs7QUFHSjtFQUVJO0VBQ0E7O0FBSUo7RUFDSTs7QUFHSjtFQUNJOztBQTl5REo7RUFDSTtJQWt6REk7O0VBQ0E7SUFBYTs7O0FBOXlEckI7RUE2eURROztBQUNBO0VBQWE7O0FBSXJCO0VBQ0k7RUFDQTtFQUdBOztBQUdKO0FBQ0k7QUFBQTtFQUVBO0VBQ0E7RUFDQTs7QUFJSjtFQUNJO0VBS0E7RUFDQTs7QUFJQTtFQUNJO0VBS0E7O0FBR0o7RUFFSTs7O0FBS1o7RUFDSTs7O0FBSUo7RUFDSTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUVBO0VBRUk7RUFDQTtFQUNBOzs7QUFJUjtFQUNJO0VBQ0E7RUFFQTtFQUdBO0VBQ0E7RUFDQTs7QUFKQTtFQUFpQjs7QUFNakI7RUFDSTtFQUNBO0VBQ0E7RUFDQTtFQUdBOztBQVFKO0VBRUk7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOzs7QUFJUjtBQUVJO0VBQ0k7RUFDQTs7QUFFSjtFQUNJO0VBQ0E7OztBQUtKO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFHSjtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7OztBQUlSO0VBRUk7RUFJQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUlBO0VBQ0k7RUFDQTs7QUFJSjtFQUVJO0VBQ0E7OztBQUtSO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFJSTtFQUE4Qjs7QUFHbEM7RUFDSTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFFQTtFQUNJO0VBQ0E7O0FBR0o7RUFDSTtJQUNJOzs7QUFLWjtFQUNJO0VBQ0E7O0FBR0o7RUFDSTtFQUVBO0VBQ0E7RUFDQTtFQUdBOztBQUdBO0VBQTREOztBQUc1RDtFQUEwQzs7QUFFMUM7RUFFSTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUVBO0VBQ0k7O0FBTVI7RUFDSTs7QUFHSjtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFHQTtFQUFtRDs7QUFDbkQ7RUFBaUQ7O0FBQ2pEO0VBQStEOztBQUMvRDtFQUNJOztBQUdKO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBTUo7RUFDSTtFQUNBO0VBQ0E7RUFDQTs7QUFFQTtFQUNJO0VBQ0E7O0FBSVI7RUFBYTs7QUFNYjtFQUNJO0lBQVU7O0VBQ1Y7SUFBbUI7OztBQUszQjtFQUNJOzs7QUFLWjtFQUVJOzs7QUFHSjtBQUNBO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQSxZQUNJO0VBRUo7RUFJQTtBQWdCQTs7QUFiQTtFQUVJO0VBQ0E7RUFDQTtFQUNBOztBQUdKO0VBRUk7O0FBSUo7RUFDSTs7QUFJSjtFQUFpQjtFQUFhOztBQUU5QjtFQUNJO0VBQ0E7RUFDQTtFQUNBOztBQUVBO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFFQTtFQUNJOztBQUtaO0VBQ0k7QUFlQTtBQUdBO0FBQUE7QUFJQTs7QUFwQkE7RUFDSTtFQUNBO0VBQ0E7RUFDQTtFQUVBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBSUo7RUFBc0M7O0FBSXRDO0VBQXFDOztBQUdyQztFQUFnQzs7QUFDaEM7RUFBK0I7O0FBRS9CO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0FBTUE7QUF3QkE7QUFBQTtBQUtBOztBQWpDQTtFQUNJOztBQUlKO0VBRUk7RUFDQTs7QUFHSjtFQUNJO0VBQ0E7O0FBR0o7RUFDSTtJQUNJO0lBQ0E7OztBQUlSO0VBQ0k7RUFDQTs7QUFLSjtFQUErQzs7QUFDL0M7RUFBeUM7O0FBR3pDO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFLUjtFQUE4Qjs7QUFDOUI7RUFBNkI7O0FBR2pDO0VBRUk7RUFDQTtFQUNBO0VBSUE7RUFDQTs7QUFHSjtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUdKO0VBR0k7OztBQU1BO0VBQ0k7O0FBR0o7RUFDSTs7O0FBS1o7QUFBQTtBQUdJO0FBQUE7QUFVQTs7QUFSQTtBQUFBO0VBQ0k7O0FBR0o7QUFBQTtFQUNJOztBQUlKO0FBQUE7RUFDSTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBT0E7O0FBTkE7QUFBQTtFQUNJO0VBQ0E7RUFDQTs7QUFJSjtBQUFBO0VBQ0k7O0FBR0o7RUFDSTtBQUFBO0lBQ0k7O0VBQ0E7QUFBQTtJQUFhOzs7O0FBTTdCO0VBRUk7OztBQUdKO0VBRUk7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUdBO0VBRUE7RUFRQTtFQUNBOztBQVJBO0VBQ0k7O0FBU0o7RUFDSTtFQUNBO0VBQ0E7RUFDQTtFQUVBO0VBQ0E7RUFFQTs7QUFFQTtFQUNJOztBQUVKO0VBRUk7RUFDQTtFQUNBO0VBQ0E7O0FBRUE7RUFHSTtFQUNBOztBQUdKO0VBQ0k7RUFDQTs7QUFJUjtFQUNJO0VBQ0E7RUFJQTtFQUNBO0VBQ0E7RUFDQTtFQUdBO0VBRUE7RUFDQTtFQUNBOztBQUlSO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBR0E7RUFHQTtBQWNBOztBQWhCQTtFQUFZOztBQUNaO0VBQWE7O0FBR2I7RUFDSTtFQUNBOztBQUdKO0VBQ0k7RUFHQTs7QUFJSjtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBRUo7RUFDSTs7O0FBTVo7RUFFSTtFQUNBO0VBQ0E7RUFDQTs7QUFFQTtFQUNJO0VBQ0E7O0FBRUE7RUFFSTtFQUNBOztBQUlSO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFHSjtFQUVJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFFQTtFQUVJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFFQTtFQUNJOzs7QUFNaEI7RUFFSTs7QUFDQTtFQUNJO0VBRUEsYUFDSTtFQUlKO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOzs7QUFJUjtBQUFBO0FBRUE7RUFFSTs7O0FBRUo7RUFFSTs7O0FBR0o7QUFBQTtBQUVBO0VBQ0k7OztBQUtBO0VBQ0k7SUFDSTs7O0FBTVI7RUFDSTs7QUFHSjtFQUNJO0VBQ0E7O0FBRUo7RUFDSTtFQUNBOztBQUVKO0VBQ0k7SUFDSTtJQUNBOzs7O0FBS1o7QUFDSTtBQUFBO0FBQUE7QUFBQTs7QUFJQTtFQUNJOztBQUdKO0VBQ0k7O0FBRUo7RUFDSTtJQUNJOzs7O0FBTVI7RUFDSTtFQUNBOztBQUVKO0VBQ0k7SUFDSTs7OztBQUtaO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUVBO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUdKO0VBQ0k7SUFDSTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7OztBQUtKO0VBQ0k7RUFDQTs7QUFFSjtFQUNJOzs7QUFJWjtFQUNJOzs7QUFFSjtFQUNJOzs7QUF1UUo7RUFwUEk7RUFDQTtFQUNBO0VBQ0E7RUFTQTtFQUlBO0VBQ0E7RUFJQTtFQWlCQTtFQUNBO0VBU0E7RUFDQTtFQUtBO0VBQ0E7RUF3REE7O0FBeEdBO0VBQ0k7O0FBVUo7RUFDSTs7QUFRSjtFQUVJO0VBQ0E7O0FBRUo7RUFFSTtFQUNBOztBQVFKO0VBQ0k7O0FBTUo7RUFDSTtFQUNBOztBQU1KO0VBR0k7RUFDQTtFQUNBO0VBQ0E7RUFFQTtFQUNBO0VBQ0E7RUFDQTs7QUFHQTtFQUNJOztBQUlSO0VBRUk7RUFDQTtFQUNBO0VBTUE7RUFDQTtFQUNBO0VBQ0E7RUFFQTtFQUNBO0VBQ0E7RUFDQTs7QUFHQTtFQUNJO0VBQ0E7O0FBYVI7RUFDSTtFQUdBOztBQUlBO0VBRUk7RUFDQTtFQUNBO0VBQ0E7RUFRQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBU1I7RUFDSTtFQUNBO0VBSUE7RUFFQTs7QUFHSjtFQUVJO0VBQ0E7O0FBSUo7RUFDSTs7QUFHSjtFQUNJO0VBRUE7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFJQTtFQUlJOztBQVFKO0VBQ0k7O0FBSVI7RUFDSTtFQUVBO0VBQ0E7RUFDQTs7QUFJQTtFQUNJOztBQUVBO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7O0FBQ0E7RUFDSTtJQUNJOzs7QUFHUjtFQUNJOztBQUtaO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7O0FBS0o7RUFFSTtFQUNBO0VBQ0E7OztBQVNaO0VBeFBJO0VBQ0E7RUFDQTtFQUNBO0VBU0E7RUFJQTtFQUNBO0VBSUE7RUFpQkE7RUFDQTtFQVNBO0VBQ0E7RUFLQTtFQUNBO0VBd0RBOztBQXhHQTtFQUNJOztBQVVKO0VBQ0k7O0FBUUo7RUFFSTtFQUNBOztBQUVKO0VBRUk7RUFDQTs7QUFRSjtFQUNJOztBQU1KO0VBQ0k7RUFDQTs7QUFNSjtFQUdJO0VBQ0E7RUFDQTtFQUNBO0VBRUE7RUFDQTtFQUNBO0VBQ0E7O0FBR0E7RUFDSTs7QUFJUjtFQUVJO0VBQ0E7RUFDQTtFQU1BO0VBQ0E7RUFDQTtFQUNBO0VBRUE7RUFDQTtFQUNBO0VBQ0E7O0FBR0E7RUFDSTtFQUNBOztBQWFSO0VBQ0k7RUFHQTs7QUFJQTtFQUVJO0VBQ0E7RUFDQTtFQUNBO0VBUUE7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQVNSO0VBQ0k7RUFDQTtFQUlBO0VBRUE7O0FBR0o7RUFFSTtFQUNBOztBQUlKO0VBQ0k7O0FBR0o7RUFDSTtFQUVBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBSUE7RUFJSTs7QUFRSjtFQUNJOztBQUlSO0VBQ0k7RUFFQTtFQUNBO0VBQ0E7O0FBSUE7RUFDSTs7QUFFQTtFQUNJO0VBQ0E7RUFDQTtFQUNBOztBQUNBO0VBQ0k7SUFDSTs7O0FBR1I7RUFDSTs7QUFLWjtFQUNJO0VBQ0E7RUFDQTtFQUNBOztBQUtKO0VBRUk7RUFDQTtFQUNBOzs7QUFjWjtFQTdQSTtFQUNBO0VBQ0E7RUFDQTtFQVNBO0VBSUE7RUFDQTtFQUlBO0VBaUJBO0VBQ0E7RUFTQTtFQUNBO0VBS0E7RUFDQTtFQXdEQTtFQWlKQTs7QUF6UEE7RUFDSTs7QUFVSjtFQUNJOztBQVFKO0VBRUk7RUFDQTs7QUFFSjtFQUVJO0VBQ0E7O0FBUUo7RUFDSTs7QUFNSjtFQUNJO0VBQ0E7O0FBTUo7RUFHSTtFQUNBO0VBQ0E7RUFDQTtFQUVBO0VBQ0E7RUFDQTtFQUNBOztBQUdBO0VBQ0k7O0FBSVI7RUFFSTtFQUNBO0VBQ0E7RUFNQTtFQUNBO0VBQ0E7RUFDQTtFQUVBO0VBQ0E7RUFDQTtFQUNBOztBQUdBO0VBQ0k7RUFDQTs7QUFhUjtFQUNJO0VBR0E7O0FBSUE7RUFFSTtFQUNBO0VBQ0E7RUFDQTtFQVFBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFTUjtFQUNJO0VBQ0E7RUFJQTtFQUVBOztBQUdKO0VBRUk7RUFDQTs7QUFJSjtFQUNJOztBQUdKO0VBQ0k7RUFFQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUlBO0VBSUk7O0FBUUo7RUFDSTs7QUFJUjtFQUNJO0VBRUE7RUFDQTtFQUNBOztBQUlBO0VBQ0k7O0FBRUE7RUFDSTtFQUNBO0VBQ0E7RUFDQTs7QUFDQTtFQUNJO0lBQ0k7OztBQUdSO0VBQ0k7O0FBS1o7RUFDSTtFQUNBO0VBQ0E7RUFDQTs7QUFLSjtFQUVJO0VBQ0E7RUFDQTs7QUFtQlI7RUFDSTtFQUNBO0VBQ0E7RUFDQTs7QUFFQTtFQUNJO0VBQ0E7OztBQUtaO0VBRUk7RUFDQTs7QUFFQTtFQUNJOztBQUdKO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7O0FBRUo7RUFDSTtFQUNBOztBQUdKO0VBQ0k7RUFDQTs7QUFFSjtFQUNJOzs7QUFJUjtFQTVTSTtFQUNBO0VBQ0E7RUFDQTtFQVNBO0VBSUE7RUFDQTtFQUlBO0VBaUJBO0VBQ0E7RUFTQTtFQUNBO0VBS0E7RUFDQTtFQXdEQTtFQWdNQTs7QUF4U0E7RUFDSTs7QUFVSjtFQUNJOztBQVFKO0VBRUk7RUFDQTs7QUFFSjtFQUVJO0VBQ0E7O0FBUUo7RUFDSTs7QUFNSjtFQUNJO0VBQ0E7O0FBTUo7RUFHSTtFQUNBO0VBQ0E7RUFDQTtFQUVBO0VBQ0E7RUFDQTtFQUNBOztBQUdBO0VBQ0k7O0FBSVI7RUFFSTtFQUNBO0VBQ0E7RUFNQTtFQUNBO0VBQ0E7RUFDQTtFQUVBO0VBQ0E7RUFDQTtFQUNBOztBQUdBO0VBQ0k7RUFDQTs7QUFhUjtFQUNJO0VBR0E7O0FBSUE7RUFFSTtFQUNBO0VBQ0E7RUFDQTtFQVFBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFTUjtFQUNJO0VBQ0E7RUFJQTtFQUVBOztBQUdKO0VBRUk7RUFDQTs7QUFJSjtFQUNJOztBQUdKO0VBQ0k7RUFFQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUlBO0VBSUk7O0FBUUo7RUFDSTs7QUFJUjtFQUNJO0VBRUE7RUFDQTtFQUNBOztBQUlBO0VBQ0k7O0FBRUE7RUFDSTtFQUNBO0VBQ0E7RUFDQTs7QUFDQTtFQUNJO0lBQ0k7OztBQUdSO0VBQ0k7O0FBS1o7RUFDSTtFQUNBO0VBQ0E7RUFDQTs7QUFLSjtFQUVJO0VBQ0E7RUFDQTs7QUFrRVI7RUFFSTs7QUFFQTtFQUdJO0VBQ0E7O0FBSVI7RUFDSTtFQUlBOztBQUdKO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFJQTtFQUNBO0VBQ0E7RUFDQTs7QUFHQTtBQUFBO0VBR0k7OztBQUtaO0VBOVZJO0VBQ0E7RUFDQTtFQUNBO0VBU0E7RUFJQTtFQUNBO0VBSUE7RUFpQkE7RUFDQTtFQVNBO0VBQ0E7RUFLQTtFQUNBO0VBd0RBOztBQXhHQTtFQUNJOztBQVVKO0VBQ0k7O0FBUUo7RUFFSTtFQUNBOztBQUVKO0VBRUk7RUFDQTs7QUFRSjtFQUNJOztBQU1KO0VBQ0k7RUFDQTs7QUFNSjtFQUdJO0VBQ0E7RUFDQTtFQUNBO0VBRUE7RUFDQTtFQUNBO0VBQ0E7O0FBR0E7RUFDSTs7QUFJUjtFQUVJO0VBQ0E7RUFDQTtFQU1BO0VBQ0E7RUFDQTtFQUNBO0VBRUE7RUFDQTtFQUNBO0VBQ0E7O0FBR0E7RUFDSTtFQUNBOztBQWFSO0VBQ0k7RUFHQTs7QUFJQTtFQUVJO0VBQ0E7RUFDQTtFQUNBO0VBUUE7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQVNSO0VBQ0k7RUFDQTtFQUlBO0VBRUE7O0FBR0o7RUFFSTtFQUNBOztBQUlKO0VBQ0k7O0FBR0o7RUFDSTtFQUVBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBSUE7RUFJSTs7QUFRSjtFQUNJOztBQUlSO0VBQ0k7RUFFQTtFQUNBO0VBQ0E7O0FBSUE7RUFDSTs7QUFFQTtFQUNJO0VBQ0E7RUFDQTtFQUNBOztBQUNBO0VBQ0k7SUFDSTs7O0FBR1I7RUFDSTs7QUFLWjtFQUNJO0VBQ0E7RUFDQTtFQUNBOztBQUtKO0VBRUk7RUFDQTtFQUNBOztBQW1IUjtFQUNJOztBQUdKO0VBQ0k7RUFDQTtFQUNBOztBQUVBO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7O0FBR0o7RUFDSTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUdKO0VBQ0k7SUFDSTs7OztBQU1oQjtFQWxZSTtFQUNBO0VBQ0E7RUFDQTtFQVNBO0VBSUE7RUFDQTtFQUlBO0VBaUJBO0VBQ0E7RUFTQTtFQUNBO0VBS0E7RUFDQTtFQXdEQTs7QUF4R0E7RUFDSTs7QUFVSjtFQUNJOztBQVFKO0VBRUk7RUFDQTs7QUFFSjtFQUVJO0VBQ0E7O0FBUUo7RUFDSTs7QUFNSjtFQUNJO0VBQ0E7O0FBTUo7RUFHSTtFQUNBO0VBQ0E7RUFDQTtFQUVBO0VBQ0E7RUFDQTtFQUNBOztBQUdBO0VBQ0k7O0FBSVI7RUFFSTtFQUNBO0VBQ0E7RUFNQTtFQUNBO0VBQ0E7RUFDQTtFQUVBO0VBQ0E7RUFDQTtFQUNBOztBQUdBO0VBQ0k7RUFDQTs7QUFhUjtFQUNJO0VBR0E7O0FBSUE7RUFFSTtFQUNBO0VBQ0E7RUFDQTtFQVFBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFTUjtFQUNJO0VBQ0E7RUFJQTtFQUVBOztBQUdKO0VBRUk7RUFDQTs7QUFJSjtFQUNJOztBQUdKO0VBQ0k7RUFFQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUlBO0VBSUk7O0FBUUo7RUFDSTs7QUFJUjtFQUNJO0VBRUE7RUFDQTtFQUNBOztBQUlBO0VBQ0k7O0FBRUE7RUFDSTtFQUNBO0VBQ0E7RUFDQTs7QUFDQTtFQUNJO0lBQ0k7OztBQUdSO0VBQ0k7O0FBS1o7RUFDSTtFQUNBO0VBQ0E7RUFDQTs7QUFLSjtFQUVJO0VBQ0E7RUFDQTs7QUF1SlI7RUFDSTtFQUNBO0VBQ0E7O0FBQ0E7RUFDSTtFQUNBOztBQUdKO0VBQ0k7RUFDQTtFQUNBOzs7QUFPWjtFQUlJO0VBQ0E7RUFTQTtFQUtBOztBQUlBO0VBQ0k7RUFDQTtFQUNBO0VBSUE7O0FBSUE7RUF2L0ZKO0VBQ0E7O0FBMi9GUTtFQUVJO0VBQ0E7RUFDQTs7QUFLWjtFQUNJO0VBQ0E7RUFHQTtFQUNBO0VBR0E7O0FBTUo7RUFHSTtFQUNBO0VBQ0E7RUFFQSxXQUNJO0VBSUo7RUFNQTs7QUFHQTtFQUNJO0VBR0E7RUFFQTtFQUlBOztBQUlSO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtBQWlCQTs7QUFmQTtFQUNJO0VBQ0E7O0FBR0o7RUFDSTtFQUNBOztBQUdKO0VBQ0k7RUFDQTs7QUFJSjtFQUNJOztBQUVKO0VBQ0k7O0FBRUo7RUFDSTs7QUFHSjtFQUNJOztBQUlSO0VBQ0k7SUFBTzs7RUFDUDtJQUFPOztFQUNQO0lBQU87OztBQUdYO0VBQ0k7SUFBTzs7RUFDUDtJQUFPOztFQUNQO0lBQU87OztBQUdYO0VBRUk7RUFDQTtFQUNBO0VBQ0E7RUFHQTtFQUVBOztBQURBO0VBQW1DOztBQUduQztFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOzs7QUFPUjtFQUNJO0VBQ0E7RUFDQTtFQUNBOztBQUdKO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUdKO0VBQ0k7RUFDQTs7QUFHSjtFQUNJOztBQUdKO0VBQ0k7RUFDQTtFQUlBOztBQUVBO0VBQ0k7RUFFQTs7QUFFQTtFQUNJO0lBQUs7O0VBQ0w7SUFBTTs7RUFDTjtJQUFNOztFQUNOO0lBQU07Ozs7QUFNdEI7RUFDSTtFQUNBO0VBQ0E7QUFDQTtBQUFBO0FBQUE7O0FBR0E7RUFDSTtFQUNBOzs7QUFJUjtFQUVJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBRUE7RUFFSTtFQU1BOztBQUNBO0VBQ0k7O0FBSUo7RUFDSTs7QUFHSjtFQUVJOztBQUdKO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7O0FBRUE7RUFHSTs7QUFRSjtFQUdJO0VBQ0E7O0FBR0o7RUFFSTs7QUFHSjtFQUNJO0VBQ0E7RUFDQTtFQUVBO0VBQ0E7RUFDQTs7QUFFQTtFQUVJO0VBQ0E7RUFDQTtFQUNBOztBQUlKO0VBRUk7O0FBR0o7RUFFSTs7QUFJSjtFQUVJOztBQUdKO0VBR0k7O0FBR0o7RUFFSTtFQUNBOztBQUdKO0VBRUk7OztBQU9wQjtFQUNJO0VBQ0E7RUFNQTs7QUFGQTtFQUFnQzs7O0FBS3BDO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBRUE7RUFDSTtFQUNBO0VBQ0E7OztBQUlSO0VBRUk7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBRUE7RUFJSTs7O0FBSVI7RUFFSTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFDQTtFQUVJO0VBQ0E7RUFDQTs7O0FBa0hSO0VBejZCSTtFQUNBO0VBQ0E7RUFDQTtFQVNBO0VBSUE7RUFDQTtFQUlBO0VBaUJBO0VBQ0E7RUFTQTtFQUNBO0VBS0E7RUFDQTtFQXdEQTtFQW10QkE7O0FBM3pCQTtFQUNJOztBQVVKO0VBQ0k7O0FBUUo7RUFFSTtFQUNBOztBQUVKO0VBRUk7RUFDQTs7QUFRSjtFQUNJOztBQU1KO0VBQ0k7RUFDQTs7QUFNSjtFQUdJO0VBQ0E7RUFDQTtFQUNBO0VBRUE7RUFDQTtFQUNBO0VBQ0E7O0FBR0E7RUFDSTs7QUFJUjtFQUVJO0VBQ0E7RUFDQTtFQU1BO0VBQ0E7RUFDQTtFQUNBO0VBRUE7RUFDQTtFQUNBO0VBQ0E7O0FBR0E7RUFDSTtFQUNBOztBQWFSO0VBQ0k7RUFHQTs7QUFJQTtFQUVJO0VBQ0E7RUFDQTtFQUNBO0VBUUE7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQVNSO0VBQ0k7RUFDQTtFQUlBO0VBRUE7O0FBR0o7RUFFSTtFQUNBOztBQUlKO0VBQ0k7O0FBR0o7RUFDSTtFQUVBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBSUE7RUFJSTs7QUFRSjtFQUNJOztBQUlSO0VBQ0k7RUFFQTtFQUNBO0VBQ0E7O0FBSUE7RUFDSTs7QUFFQTtFQUNJO0VBQ0E7RUFDQTtFQUNBOztBQUNBO0VBQ0k7SUFDSTs7O0FBR1I7RUFDSTs7QUFLWjtFQUNJO0VBQ0E7RUFDQTtFQUNBOztBQUtKO0VBRUk7RUFDQTtFQUNBOztBQXdsQlI7RUFDSTtFQUNBOztBQUtBO0VBRUk7O0FBS1I7RUFDSTtFQUNBO0VBQ0E7RUFDQTs7QUFFQTtFQUNJO0VBQ0E7O0FBRUE7RUFDSTs7QUFHSjtFQUNJOztBQUdKO0VBQ0k7SUFDSTs7O0FBS1o7RUFDSTs7QUFNUjtFQUVJO0VBSUE7O0FBR0o7RUFDSTtFQUNBO0VBQ0E7O0FBRUE7RUFDSTs7QUFHSjtFQUVJO0VBQ0E7RUFDQTtFQUNBOztBQUdBO0VBQ0k7O0FBSUo7RUFDSTtFQUNBOztBQU1BO0VBQ0k7O0FBSVI7RUFDSTtFQUNBOztBQWFSO0VBQ0k7O0FBTUo7RUFDSTs7QUFJUjtFQUdJO0VBQ0E7RUFHQTs7O0FBSVI7RUF2OEJJO0VBQ0E7RUFDQTtFQUNBO0VBU0E7RUFJQTtFQUNBO0VBSUE7RUFpQkE7RUFDQTtFQVNBO0VBQ0E7RUFLQTtFQUNBO0VBd0RBO0VBbXRCQTs7QUEzekJBO0VBQ0k7O0FBVUo7RUFDSTs7QUFRSjtFQUVJO0VBQ0E7O0FBRUo7RUFFSTtFQUNBOztBQVFKO0VBQ0k7O0FBTUo7RUFDSTtFQUNBOztBQU1KO0VBR0k7RUFDQTtFQUNBO0VBQ0E7RUFFQTtFQUNBO0VBQ0E7RUFDQTs7QUFHQTtFQUNJOztBQUlSO0VBRUk7RUFDQTtFQUNBO0VBTUE7RUFDQTtFQUNBO0VBQ0E7RUFFQTtFQUNBO0VBQ0E7RUFDQTs7QUFHQTtFQUNJO0VBQ0E7O0FBYVI7RUFDSTtFQUdBOztBQUlBO0VBRUk7RUFDQTtFQUNBO0VBQ0E7RUFRQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBU1I7RUFDSTtFQUNBO0VBSUE7RUFFQTs7QUFHSjtFQUVJO0VBQ0E7O0FBSUo7RUFDSTs7QUFHSjtFQUNJO0VBRUE7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7QUFJQTtFQUlJOztBQVFKO0VBQ0k7O0FBSVI7RUFDSTtFQUVBO0VBQ0E7RUFDQTs7QUFJQTtFQUNJOztBQUVBO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7O0FBQ0E7RUFDSTtJQUNJOzs7QUFHUjtFQUNJOztBQUtaO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7O0FBS0o7RUFFSTtFQUNBO0VBQ0E7O0FBd2xCUjtFQUNJO0VBQ0E7O0FBS0E7RUFFSTs7QUFLUjtFQUNJO0VBQ0E7RUFDQTtFQUNBOztBQUVBO0VBQ0k7RUFDQTs7QUFFQTtFQUNJOztBQUdKO0VBQ0k7O0FBR0o7RUFDSTtJQUNJOzs7QUFLWjtFQUNJOztBQU1SO0VBRUk7RUFJQTs7QUFHSjtFQUNJO0VBQ0E7RUFDQTs7QUFFQTtFQUNJOztBQUdKO0VBRUk7RUFDQTtFQUNBO0VBQ0E7O0FBR0E7RUFDSTs7QUFJSjtFQUNJO0VBQ0E7O0FBTUE7RUFDSTs7QUFJUjtFQUNJO0VBQ0E7OztBQXlDaEI7RUE1OEJJO0VBQ0E7RUFDQTtFQUNBO0VBU0E7RUFJQTtFQUNBO0VBSUE7RUFpQkE7RUFDQTtFQVNBO0VBQ0E7RUFLQTtFQUNBO0VBd0RBOztBQXhHQTtFQUNJOztBQVVKO0VBQ0k7O0FBUUo7RUFFSTtFQUNBOztBQUVKO0VBRUk7RUFDQTs7QUFRSjtFQUNJOztBQU1KO0VBQ0k7RUFDQTs7QUFNSjtFQUdJO0VBQ0E7RUFDQTtFQUNBO0VBRUE7RUFDQTtFQUNBO0VBQ0E7O0FBR0E7RUFDSTs7QUFJUjtFQUVJO0VBQ0E7RUFDQTtFQU1BO0VBQ0E7RUFDQTtFQUNBO0VBRUE7RUFDQTtFQUNBO0VBQ0E7O0FBR0E7RUFDSTtFQUNBOztBQWFSO0VBQ0k7RUFHQTs7QUFJQTtFQUVJO0VBQ0E7RUFDQTtFQUNBO0VBUUE7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQVNSO0VBQ0k7RUFDQTtFQUlBO0VBRUE7O0FBR0o7RUFFSTtFQUNBOztBQUlKO0VBQ0k7O0FBR0o7RUFDSTtFQUVBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBSUE7RUFJSTs7QUFRSjtFQUNJOztBQUlSO0VBQ0k7RUFFQTtFQUNBO0VBQ0E7O0FBSUE7RUFDSTs7QUFFQTtFQUNJO0VBQ0E7RUFDQTtFQUNBOztBQUNBO0VBQ0k7SUFDSTs7O0FBR1I7RUFDSTs7QUFLWjtFQUNJO0VBQ0E7RUFDQTtFQUNBOztBQUtKO0VBRUk7RUFDQTtFQUNBOztBQWl1QlI7RUFDSTtFQUNBO0VBQ0E7O0FBRUE7RUFDSTtFQUVBO0VBR0E7OztBQUtaO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTs7O0FBTUE7QUFBQTtFQUNJO0VBQ0E7RUFDQTtFQUNBOztBQUlBO0FBQUE7RUFDSTtFQUNBOztBQUlSO0FBQUE7RUFHSTs7QUFFQTtBQUFBO0VBQ0k7RUFDQTs7QUFJUjtBQUFBO0VBQ0k7RUFDQTtFQUNBOztBQUVBO0FBQUE7RUFDSTtFQUNBO0VBQ0E7O0FBQ0E7QUFBQTtFQUNJOztBQUtaO0FBQUE7RUFDSTtFQUNBOzs7QUFJUjtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUVBO0VBQ0k7RUFDQTs7QUFHSjtFQUNJOztBQUdKO0VBQ0k7O0FBRUo7RUFZSTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBZkE7RUFDSTs7QUFHSjtFQUNJOztBQUVKO0VBQ0k7O0FBU0o7RUFBVTs7QUFDVjtFQUFZOztBQUNaO0VBQVc7O0FBRVg7RUFDSTs7QUFJQTtFQUNJOztBQUtaO0VBQ0k7SUFBTzs7RUFDUDtJQUFPOzs7O0FBSWY7RUFDSTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUVBO0VBQ0k7RUFHQTs7QUFFQTtFQUVJOztBQUdKO0VBQ0k7RUFDQTs7QUFHSjtFQUVJO0VBQ0E7RUFJQTs7QUFHSjtFQUNJOztBQUVBO0VBQTZCOztBQUM3QjtFQUE2Qjs7QUFDN0I7RUFBNkI7O0FBQzdCO0VBQTZCOztBQUM3QjtFQUE2Qjs7QUFDN0I7RUFBNkI7O0FBQzdCO0VBQTZCOztBQUM3QjtFQUE2Qjs7QUFDN0I7RUFBNkI7O0FBRzdCO0VBQTZCO0VBQWdCO0VBQWlCOztBQUM5RDtFQUE2QjtFQUFnQjtFQUFpQjs7QUFDOUQ7RUFBNkI7RUFBZ0I7RUFBaUI7O0FBQzlEO0VBQTZCO0VBQWdCO0VBQWlCOztBQUU5RDtFQUE2QjtFQUFnQjtFQUFpQjtFQUFxQzs7QUFDbkc7RUFBNkI7RUFBZ0I7RUFBaUI7RUFBcUM7O0FBQ25HO0VBQTZCO0VBQWdCO0VBQWlCO0VBQXFDOztBQUNuRztFQUE2QjtFQUFnQjtFQUFpQjtFQUFxQzs7QUFDbkc7RUFBcUI7RUFBZ0I7RUFBaUI7OztBQUtsRTtFQUNJO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBRUE7RUFDSTs7QUFHSjtFQUVJOztBQVVBO0VBQ0k7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUdKO0VBQ0k7O0FBRUE7RUFDSTs7QUFJUjtFQUNJOztBQUdKO0VBR0k7O0FBR0o7RUFDSTtFQUNBO0VBQ0E7RUFDQTs7O0FBS1o7RUFDSTtFQUNBO0VBQ0E7RUFDQTtFQUNBOztBQUtBO0VBQ0k7O0FBR0o7RUFDSTtFQUNBO0VBQ0E7O0FBR0o7RUFDSTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7O0FBRUE7RUFFSTtFQUNBO0VBQ0E7RUFDQTs7O0FBVUo7RUFDSTtFQUNBO0VBQ0E7RUFDQTs7O0FBS1o7RUFFSTtFQUNBO0VBQ0E7O0FBRUE7RUFFSTs7O0FBSVI7RUFFSTtFQUNBO0VBR0E7RUFDQTtFQUNBO0VBR0E7RUFHQTtFQUNBO0VBRUE7RUFDQTtFQUlBO0VBR0E7O0FBLzhIQTtFQUNJO0lBaTlIQTs7O0FBNThISjtFQTQ4SEk7O0FBR0o7RUFFSTtFQUNBO0VBQ0E7RUFDQTs7QUFHSjtFQUVJO0VBQ0E7RUFDQTtFQUNBOztBQUdKO0VBRUk7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7OztBQUlSO0VBRUk7RUFDQTtFQUNBOzs7QUFHSjtFQUNJIiwKImZpbGUiOiAid2ViL3Jlc291cmNlcy9tYWluLnNjc3MiCn0= */`); env.resources["resources/manifest.json"] = loadBlob("application/json", `{ "short_name": "vview", "display": "fullscreen", "scope": "/", "background_color": "#202020", "icons": [{ "type": "image/png", "sizes": "192x192", "src": "/vview/resources/vview-icon.png" }] }`); env.resources["resources/multi-monitor.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/noise.png"] = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAA9QTFRFIiIiISEhIyMjHx8fICAgSEHIqwAAC3FJREFUeNrEWwmS4zgM46H/v3lFALKdtA9ZqdrZ2eruOLYOigTBw2bNI8zMPSyj/+gfmmf/4Ob9evRPFvUz+ufsV3FTfVV/pXm/1CJwc//Uf7V6pH7171tmv1i39a8yMVMN5uE1Pp7jF+59lOxP1MD9oYZF1dVW9wTurgvh2Vq/ln2KrK/rEQ2Me2p5juVn7UibqhtqkX1Ex68aAHtyzI4d9IVm7dxwvdaZfbC61P/Dx5qrj9Oidmu1in5nTeh9MRinFuv4xrDzaBQXpUmhjY2XxEq0/ck+ZotaRq2iL7rVOI0rq8my1YZbiRXDY5Kxijqd/kU6lu0YsGRUR4pJa/utpg5+dNyF1eDEuBivxTbtwrD5Einur7XWRHWxRsUTXXQ4r1IhCHZoAf7Qpg0D1x01UHA7UVfquZqjBaXKhfUffbONmolN9mkSCseF1+JKlRqVFDqBLZY61OJrV9gM5tOMdeyRLl0ZKoARjbpGlcWaalgM3+XeSqz4QY0MN4kREtGia9pmQ6JUuuRMVMw6lJCosWytpizEptW9SbA1IY6qnky7VffQXmvU3M2tydyitMKgcFmbd9hTUkFkkbiQPBx97kJodXDQX2mTUdW7DDMJK0Zb6daB8a32khST0X5SgqoVZP0ri6s11sKjbKJugUJCIJAu9Le23uoyBInvqNMewKnSgEbF0k8YpBGfiD51KoVwFFYJiDYfsHA+BN0pOQSUiVrNfVIJeBXajbOrcQsyaOWtbJGy9PqrCfwa4LKG7EKAygk76mMQZaIEkHi6JOhcZ0qFMbfzWAoxpu0Fplb3AtJKF1LncWrgCVk6BqSKDHG3zZoi4tTAC7biGmBcAKMjTYEz7Y8DQlus0BEYiVtLU7k2T6Injo9X5M/6uSYtEHae2iDRSUdNdYLZlIUBO2pi7pBgj2NygHN9gh3VzriJ2h1NBCdj0GfKnstqtDkidHnCOqc6VKgAdI2QTPMpmUPRqUZd9yCvhEXKkulPAqBS5+H8sgZKumPYEUEJqNPtsQZNInUOJUqTvuIeaiFNBMCBk2lE1+HGahfcUw0tadKTBCSZmx7D7HFctSg5f4nS4cxyc42FqWMGcAo4RsBuf6alnN4zytTTKXQBrkH83LI8s8GWB8JBQrVoK3SCspb+l7G4VI2jAWhr81yg88BLTIBqI6GwRyWhQ2o4ZqMsiONh44RfK95xzAXFkwU0I5isuMADC5p2gSEX6JnaOFE51smQbzt+SYbokxpvt3vKZzJWOk1qQyN/hBKcUT7fKR8M8ZJykhOSDjl8xvCOL7bVSMbqofKlMPLi6hCZwatA5WgUIXdA/5Yh39CVphWI4hTcpRF0qU98JykPkXHAPOAJHLl0ETSDdE80eVALKOdmrHc0+k6mvygwlphECXoxuOPSrBoXgjFAM+iLK1xJMekMEZtB6hRqkHEkhsEJiJg0Wm9NnIiCAEGUOmMb6DyW+Mq9FMErxQzqZStgLFVrSWdefGxz46UxJI7SpDqA3TAXuPGsrhyBwQQMACI/kosRTAChFYnW6gJPJglVEMJk1lPRyxe5GVzSBD+FMqkztWFHODXLLfZJ+eVWaJVGqic8u2BrPqh1Uj2CTjxpOLBJOM/3tDoOtPrC4fnB4fmNw2XcNEM+r7ZzRz5d5BOzH8iv7+TXOd4pXLQdLvwarhihQ8VJMMWcUsSSxPwcrhoW9kGWXGTJ5CCeyZo12jx2MnyI4oonsubiRjDl1UxNY8blVaZGCZUQr35i8feG9hEmTaYJ5OgLeZv4zQIAQK7NXwGAuH8IFsW6llIrg034nduNEVbCC/x1+zbI57sc12fQ37bTg/qD0kCfEiBjigxIZ6BIJhNJ41cF9jR3xtYMXhtDpood7jV9hVKHjRzdWPZ8GPAHFSfCgD+oGELFICMZ7A4ZAaRGsIwGo0Mupt8YDFv/et7phOKBXTaxy7K3c8V6QZNX+GTsig2HPuLEJsK0OdqRoUlkPhBI0emHvCSkMZXLajbCkgJ2HJkwN45+0nc/WUYy46eXsiq7YwNFu4qgbCp1QM/XfJHb0Vt8pz2SaUyJ2CjYGKYw9sS1n6c9btMutqddQCiecEPpBcRBf3LzoNLURMJ0yQqL46qc1gONhv2G/L7cOaznK5H5qm5ws7ZHTJW6vix3xF7uALSNVA3oS5JkUlfluuFPcHIZYi2xOfEG5Xo653sZv0+vpfRMudSLsHcSGFcrLTsBgD/zCpaSCZE2IN1HdjbodZzRBSkAoK38SxpTV4J4rnaEktJmMEbaWMMtUPkgOf3fDwDBDAw9XCrxjkp+5v2+clZwjbMOfk83vxA5U4iDD0bMiDwlcmpWY4GgwduDTE3x/6sQdIb/f6crlOp0AvV39vslP50urx34ae78dLCfp/qsjWwhnTNJtsONTwLWMeowRR3yWd+JN6UPGKsVMoJVbQWmZJIkVAhZYTQmRtOo0NJ+2Cc535uA7zZ2DxF3RVooICRWioQ5uJUvINgxceg/JLnaoOe/VO5WyLTtKQZBF/UwRJIY9Eajx4T5cKlJ5QlW8lS5JVwRFRtS9wY5ZI48ECsCm2i2FDllmGd3hO5g9Hq7wq2AmFtKCiIhaXSW0i5WCL7DlLuKivIBoCTtkK+4qYmsMOl+fiPcTAaQRp4bPODBIyB9uCj+7VuMZWKmIwBdL/vYPH5e5G8wTUt/Y86K5IpO5JoMUzKE7CajEVM0gv8ZDcH4c8n6d/60kuLOnT9hS/cFnofw5JD6nQ5PQuEJnaC4+sscwxYrrNAoU6wQAqHpvN6ZXn7BvHB/Wi8JNK/D26NC3bRqXEU4sUc4qTreevn2Gar+lm9t8BBLFjjOG2x8qsHnO2tXYMvWi+sGH1eDjyvJ/G+zZH5Odp2w81yYPpDd6fgmRbYbe0p+coa24gxN7lpK/IsR0hBWjZBSPjBcHrkahtJEdEP82Qf+i0iHVOdtB6SGNHJW1Tb+9gfN9sdd9Adl3PQnmfqT4M9zoUryESW9rJKwYLMdZGMT0ElvyDxJ2XtDQr0hTyTFBG7ixctlY/rHhbKxq/uTe1WD5yozXMmxtp3V+HNh8ClqeCgMnkUNOTp5MNj7aOxDsSajsQ+EiCNC2E2X10yXmfRODXxAYkHndZeZH7rMlGE/Ted5TNTk3nZ+4RiVThzdjFMteFdJiBUM870F0JnBuQeu28bKkRB+01hpAk6gx11FbqZwclGRuy2cuOp1Q76vz/2YlZk996s0MpX+h/hwBnq/eaXt0O/DOT40jl6nzlZKzq7UGZpMXrQB0+N8tSF/9zmrRBlUo2DfjMswtD3xXQe2+utK4WePzm99aNzfY8fcXeJutsZ9ZlGIOeO3gkksFEy2rgcI0yezm1fcbwm/tTusP992MH6dx0rPgek8mnT9b5/Ni3LoyksZivBteF+Y3fayAASIw5vqknubWw7RR7VOsIzw1AF534rB8nNjYlTpkpbi7YFivSPmCOrf3oFJRHupwX90bLbh6OArmnzFaHD9iZaulP5zT1gyS/aTGLeUpTX1WqKGlGFbI/GoMquPmYV33wLopfzkHjktlBtMBJfSnn5D7DxGWXk9yfYYxfSu1HLFJlaoVBPQ2Whnm8hJXx7wi1cRNs7jO+dpQ7DLOfuV7hdTZDYq5L90Fdv7ruLqpOCJjszty+TsZ7PEZI37o5HDlVqzrQ61UnvThfO+2ft+ICXqYrwxNv9q3Um+9vHVupN8bagjhWfwz3vJXhfdvrOIM+/kfWYRc88ixu07oVPx6tQ7eZ/xqo6X7MhftGSfqtZ9D/Mpt8qdW9H+n7kLI8oz7rRCJ00oHwSn/52PROyxK0O3y/chZ9IGtcoWzHwyX6hA7DptYEobqCx2ipa+o+X9OyAPJ3TKbkNozXfOliMCOuqXXcgfjppt5f+4m+4/AQYA7rFTSAXDOQYAAAAASUVORK5CYII="; env.resources["resources/page-icon-hover.png"] = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAGu2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0zMFQwMjowMToxNy0wNTowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMC0wMS0xMlQxODo1NDowNi0wNjowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjAtMDEtMTJUMTg6NTQ6MDYtMDY6MDAiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6YTk5ZGEzMWUtNTM5Zi03OTQ3LWI5MzMtNzRiYzliZDMyYjRmIiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6MjE2NDcxMGYtOTM5My1hZjRmLTg3ZWUtY2NmY2YxNGIzMjYzIiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6ZTAzMzRhNzEtMmQ2Mi1lNDRjLWFiMjUtZGJjZTNlYTcwNTYwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIj4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDplMDMzNGE3MS0yZDYyLWU0NGMtYWIyNS1kYmNlM2VhNzA1NjAiIHN0RXZ0OndoZW49IjIwMTgtMDYtMzBUMDI6MDE6MTctMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjYxNjM4NWIxLTA4YjUtZDI0YS1iNDMyLTcwM2IwZjQxYzYxOSIgc3RFdnQ6d2hlbj0iMjAxOC0wNi0zMFQwMjowMToxNy0wNTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6YTk5ZGEzMWUtNTM5Zi03OTQ3LWI5MzMtNzRiYzliZDMyYjRmIiBzdEV2dDp3aGVuPSIyMDIwLTAxLTEyVDE4OjU0OjA2LTA2OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+iRnHuAAAAEFJREFUOI1j/P//PwMlgIki3QwMDCxIbEJOYaSJC0g14D8Dmkvp7oLhaAByOsAaz6QYAAMkpW2qegEGSPLKwMcCANSMByRCC4aMAAAAAElFTkSuQmCC"; env.resources["resources/page-icon.png"] = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFwmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0zMFQwMjowMToxNy0wNTowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAxOC0wNi0zMFQwMjowMToxNy0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDYtMzBUMDI6MDE6MTctMDU6MDAiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NjE2Mzg1YjEtMDhiNS1kMjRhLWI0MzItNzAzYjBmNDFjNjE5IiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6YmNlMzU0ZTgtMDE3Zi1iMjRkLTg4MTYtOGZkZTZlYTgyZDg5IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6ZTAzMzRhNzEtMmQ2Mi1lNDRjLWFiMjUtZGJjZTNlYTcwNTYwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyI+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6ZTAzMzRhNzEtMmQ2Mi1lNDRjLWFiMjUtZGJjZTNlYTcwNTYwIiBzdEV2dDp3aGVuPSIyMDE4LTA2LTMwVDAyOjAxOjE3LTA1OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo2MTYzODViMS0wOGI1LWQyNGEtYjQzMi03MDNiMGY0MWM2MTkiIHN0RXZ0OndoZW49IjIwMTgtMDYtMzBUMDI6MDE6MTctMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+1Ie7qQAAADpJREFUOI1j/P//PwMlgIki3dQwgAWNT8g/jNRwAYolAx8GowZgpgOMeCbVAAYGwomJoAEkuWLgAxEAc7EGJRNwU4UAAAAASUVORK5CYII="; env.resources["resources/pan-editor-marker.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/picture-in-picture.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/play-button.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/ppixiv.woff"] = "data:application/x-font-woff;base64,d09GRgABAAAAAA6QAAwAAAAADkAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABHU1VCAAABHAAAAQQAAAEE3L3f2U9TLzIAAAIgAAAAYAAAAGAPahxyY21hcAAAAoAAAAE4AAABOOq+w9xnYXNwAAADuAAAAAgAAAAIAAAAEGdseWYAAAPAAAAH5AAAB+SeTEvnaGVhZAAAC6QAAAA2AAAANiE1UAdoaGVhAAAL3AAAACQAAAAkB18Di2htdHgAAAwAAAAAjAAAAIwv0gLgbG9jYQAADIwAAABIAAAASBCQEnRtYXhwAAAM1AAAACAAAAAgACwAmG5hbWUAAAz0AAABegAAAXrO/VPCcG9zdAAADnAAAAAgAAAAIAADAAAAAQAAAAoAHgAsAAFsYXRuAAgABAAAAAAAAAABAAAAAWxpZ2EACAAAAAEAAAABAAQABAAAAAEACgAAAAEAEgAGACIAMgBAAFAAbACyAAEABgAHAA4AEgAUABUAGAABAAQAHgAFABEAEQAVAAsAAQAEABwABAAMABAADQABAAQAIgAFAAwAGQAMABcAAQAEACEACwAWAAoACgAJABQAFQAMABEAEAAUAAQACgAaACgAPgAdAAcAGAAMABUAFQAJABMAHwAGABgADAAVAAgACwAbAAoACwAWAA8ABwAQAAYADAAOABQAIAADAAYACgABAAQAGgAJAAsABgAVABQABQAQAAkAGAADA/wBkAAFAAACmQLMAAAAjwKZAswAAAHrADMBCQAAAAAAAAAAAAAAAAAAAAEQAAAAAAAAAAAAAAAAAAAAAEAAAP//A2b/ZwCZA2YAmQAAAAEAAAAAAAAAAAAAACAAAAAAAAYAAAADAAAANAAAAAQAAACkAAEAAwAAADQAAQAEAAAApAADAAEAAAA0AAMACgAAAKQABABwAAAAGAAQAAMACAABACAAMABfAGMAZQBpAHAAeOkH//3//wAAAAAAIAAwAF8AYQBlAGcAawBy6QD//f//AAH/4//U/6b/pf+k/6P/ov+hFxoAAwABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAAAAJQAAAAAAAAACwAAAAAAAAABAAAAAQAAACAAAAAgAAAAAwAAADAAAAAwAAAABAAAAF8AAABfAAAABQAAAGEAAABjAAAABgAAAGUAAABlAAAACQAAAGcAAABpAAAACgAAAGsAAABwAAAADQAAAHIAAAB4AAAAEwAA6QAAAOkHAAAAGgAB8ysAAfMrAAAAIgABAAH//wAPAAEAAAAAAAAAAAACAAA3OQEAAAAAAQAAAAAAAAAAAAIAADc5AQAAAAABAAAAAAAAAAAAAgAANzkBAAAAAAEAAAAAAAAAAAACAAA3OQEAAAAAAQAAAAAAAAAAAAIAADc5AQAAAAABAAAAAAAAAAAAAgAANzkBAAAAAAEAAAAAAAAAAAACAAA3OQEAAAAAAQAAAAAAAAAAAAIAADc5AQAAAAABAAAAAAAAAAAAAgAANzkBAAAAAAEAAAAAAAAAAAACAAA3OQEAAAAAAQAAAAAAAAAAAAIAADc5AQAAAAABAAAAAAAAAAAAAgAANzkBAAAAAAEAAAAAAAAAAAACAAA3OQEAAAAAAQAAAAAAAAAAAAIAADc5AQAAAAABAAAAAAAAAAAAAgAANzkBAAAAAAEAAAAAAAAAAAACAAA3OQEAAAAAAQAAAAAAAAAAAAIAADc5AQAAAAABAAAAAAAAAAAAAgAANzkBAAAAAAEAAAAAAAAAAAACAAA3OQEAAAAAAQAAAAAAAAAAAAIAADc5AQAAAAABAAAAAAAAAAAAAgAANzkBAAAAAAEAAAAAAAAAAAACAAA3OQEAAAAAAQAAAAAAAAAAAAIAADc5AQAAAAABAAAAAAAAAAAAAgAANzkBAAAAAAEAAAAAAAAAAAACAAA3OQEAAAAABAAJ/6ED9wNfABQAKQAtADEAAAEnNy8BBycPARcHFwcfATcXPwEnNwcXDwEnBy8BNyc3Jz8BFzcfAQcXBwUzFSMRMxEjA/dvD6VWnJxWpQ9vbw+lVpycVqUPb84MfkF2dkF+DFVVDH5BdnZBfgxVVf6pXFxcXAGAf6klkkNDkiWogH+pJZJEQ5EmqH9ggBxvMzNvHIBgYX8cbzMzbh1/YWApWwHJ/u0ABACgACADYALgAAMACAANABIAABMhESEBIREhEQEhESERKQERIRGgASX+2wGbASX+2/5lASX+2wGbASX+2wLg/tsBJf7bASX+Zf7bASX+2wElAAAAAwBT/9MDrQMtABoANQA5AAABJwcXFhQHBiIvAQcXFhcWMjc2NzY3NjQnJicFJyY0NzYyHwE3JyYnJiIHBgcGBwYUFxYfATcTAQcBA3OUS5UuLi6DLpRKlCYxMGUwMSYnExMTEyf9+ZUuLi6DLpRKlCYxMGUwMSYnExMTEyeUSyUBKUv+1wGAlEqULoMuLi6VS5QnExMTEycmMTBlMDEmSpQugy4uLpVLlCcTExMTJyYxMGUwMSaUSgEE/tdLASkAAQAoAAgD2AL4AEcAADceATMyNjcuASceATMyNjcuATU8ATUeARcuATU0NjcWFx4BFxYXLgE1NDYzMhYXPgE3DgEHPgE3DgEHHAEVFAcOAQcGIyImJygLFwxEezJAYxIJEgkNGgxCWRMtGCcwDg0jLSxmODk8AwJxUSlKGiE+HAssHh04GhMxHCQkjGdnhVKXQF0BASsmAUo5AgIEAw1oRQEBAQsMARlTMhowFSsjIzQPDwMKFgtPbyAcBhgQITYSBA8LHTEUBg0GXmBgnDExLSgAAAABAED/wAPAA0AAJQAAATUDIwcnIwMRIxUjFTMRFBY7AREzNTQ2NzMyFx4BFxYdATMRMzUDb607Xl0znWtRUR4VNW1hRgE4MjJKFhVQUQHnRQEU4sL+jwFfIGz+yBQe/qicQVsBFBRGLi41OgE1qgAABABf/5UDcwLMAAkAEQAVABkAABMHETMVMzczNxEFIREHIwc1IzczNSMXMzUjlTbFbGuh1/1qAk59xWuhxEhIxUhIAsyP/cNra9cB9Uf+dn1sbKHW1tYAAAEAUP/gA5ADIAASAAATASEHJiIHBhQXFjI3NjQnNxEBUAHAAYCAEjwSEhISPBISEoD+QAFgAcCAEhISPBISEhI8EoD+gP5AAAgAUf+ZA68DUwANACMAMgBAAFIAZAByAJUAACUjIiY1NDY7ATIWFRQGByMiBhUUFjsBFBY7ATI2NTMyNjU0JgMiJj0BNDYzMhYdARQGIwEjIiY1NDY7ATIWFRQGNyImLwEmNDc2Mh8BFhQHDgEjISImJyY0PwE2MhcWFA8BDgEjFyMiJjU0NjsBMhYVFAYHPgE1NCcuAScmIyIHDgEHBhUUFhceARUUFjsBMjY9AT4BNwI+fAwSEgx8DBISDHwMEhIMEBINHg0SEAwSEkoNEhINDRISDf7rew0SEg17DRISQAYLBVcJCQkZCVgJCQULBgGQBgsFCQlXChkJCQlXBQsGyHsNEhINew0SEv4aHxEROycnLS0nJzsRER0ZCzsSDHwMEgE0DhQSDQwSEgwNEh8SDQwSDRISDRIMDRICphIMfAwSEgx8DBL+6hINDRISDQ0SyQQFVwkZCQkJVwkZCQUEBAUJGQlXCQkJGQlXBQTJEg0NEhINDRJyHUoqLScnOxERERE7JyctKEkcDVMoDRISDQIlTRAAAAIAfAAiA4QC3gAqAD4AAAEeARUwFDkBFAYHDgEjIiYxFR4BByMmNjcRDgEHFgYxJzA3PgE3NjMyFhcDPgE1NCYnLgEjIgYHER4BMzI2NwMkLDQ4Li97Rk98Dh0Ojg4cDyQnCgsYMR8gclBQYUt9LkwhIyEfIFs7MW0lImQ9bSUgAosnbT8BP2wlJSgmewQRDg0SBAIMHC8SIgpPHx9LHx8sKP6HIFQzNVkiISkeGv53ERUmIAAAAAABAAAAAQAAnDXidV8PPPUACwQAAAAAAN8PhgsAAAAA3w+GCwAA/5UD9wNfAAAACAACAAAAAAAAAAEAAANm/2cAAAQAAAAAAAP3AAEAAAAAAAAAAAAAAAAAAAAjBAAAAAAAAAAAAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAJBAAAoAQAAFMEAAAoBAAAQAPSAF8EAABQBAAAUQQAAHwAAAAAAAoAFAAeACgAMgA8AEYAUABaAGQAbgB4AIIAjACWAKAAqgC0AL4AyADSANwA5gDwAPoBTgF4AdgCQgJ6AqYCygOWA/IAAQAAACMAlgAIAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAA4ArgABAAAAAAABAAYAAAABAAAAAAACAAcAVwABAAAAAAADAAYAMwABAAAAAAAEAAYAbAABAAAAAAAFAAsAEgABAAAAAAAGAAYARQABAAAAAAAKABoAfgADAAEECQABAAwABgADAAEECQACAA4AXgADAAEECQADAAwAOQADAAEECQAEAAwAcgADAAEECQAFABYAHQADAAEECQAGAAwASwADAAEECQAKADQAmHBwaXhpdgBwAHAAaQB4AGkAdlZlcnNpb24gMS4wAFYAZQByAHMAaQBvAG4AIAAxAC4AMHBwaXhpdgBwAHAAaQB4AGkAdnBwaXhpdgBwAHAAaQB4AGkAdlJlZ3VsYXIAUgBlAGcAdQBsAGEAcnBwaXhpdgBwAHAAaQB4AGkAdkZvbnQgZ2VuZXJhdGVkIGJ5IEljb01vb24uAEYAbwBuAHQAIABnAGUAbgBlAHIAYQB0AGUAZAAgAGIAeQAgAEkAYwBvAE0AbwBvAG4ALgAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; env.resources["resources/regular-pixiv-icon.png"] = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMAAAADACAMAAABlApw1AAAAG1BMVEX///8Alvqe1/1SuPwYoPvn9v/F5/40rPt2x/37JN9BAAAEBElEQVR42u3c23YsERAG4EJR3v+J92SSycqe0d2UYkh+F7nKEl+jHAJymycCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIBVACwhe59SjJGIbj9TSj4H4fUBfCt5ouOUfJBlAZx9pJqUsqwHCJWF/0rRh4UA3Fj6h0GWAHBIpE0pvB0gmm//sxryOwGczz/+PX5+htKTXwrvArA/ad63oP+zhctZdFUTqK/tHJZdjvv6ASLxbIAkZYCXHO26AtkWP+W671iOWppKIMO2HzN3Nj9FTyCz4jfH82Id5hmAUhNOmhE1FwR+OKD04ZJyQsCpuyOQwVfrGUlzr6AREKJZAH/k2JkhdXde6pwTS+wSUN+fMgAUBUMAR9Oe7lVJQeDtAXI4D+tfVknHeEAdvdcMUOrJYgvIJ5N5A0Ah/2gJ4NM1iwXAJW0jovZOlvwAAEdlI6LmBprdCIC6EVFrzsGNAWgb0SXg/9JGcaMAr7E0sgEgvZZ/EKAwUOZ+QCp9kkEAVlUBNYTPR36DALoqoPbyDwOIJhBRffnFDQYUAlHoADwPv+KGA4JiXk3149d4gHudp3BHE/JHE/RxAEU3prrpSXRTALm9DVFddjIHwO1tiKqigndzAIo4RDWB+WlEHAjwzatjqsnPu1mA9kB6MReKpUwGAgrL+z5AKGUyEOCaV/dU1Sin9QEXW3vxFeBzMJBpgOZ12eWKLM+tgeYwRDXfZF4nbh+LqSIwPG8Wbwa4rObBgLgXIPw+AAEAwJ/qxL8QsNk4MGAqMReQdgeYT6cnA9h8QTMZEKyXlLMB3npRPxuQjLdVZgOYbDe2pgOC8dbidECy3dydDlC0oLUAWfF/yqUAmoNPKwFUp4ZWAii68FIA3bGtdQCsO7y4DsDrzs0tAxDlOfBlAFF3XGgZgNceJlwEkNUXCdYAFCJo5I0AhQ5sfXJ3KKB0Kt787PRAQFf5FwCEvptAbwf03mR6M6B4rDy5bQDFWwltN8kMAOor2eVLIY13ugwASsLBVeTWrEwAH/UeTIofm68EWgHujyxUX/86uknt2wtjB/h6s4MrSh8Nr6XbAu7FSCcPqMjJwx9edaXRHvBgeJ9zCCLCtyQS8sWDK8oLsaMArU9LqAcTA0B8Y/FNANz5skff2yQWgHvnVD6u0v06jBHANbwsZPcwjC3gKkr2DHzTAPeaCD7Fy8Jnq0eq7AFfio8HwkoX8JNd2UcCvtuUhNsI9pHCLVm/cDYBMD4BAAAAAAAAAAAAAPC3AM+bypl3AnDpkTkv2wBytNwWnA8Ix/sLewBONn9kC8DJ+668BYD9UQq7RKHlEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADQnP4BoAuIrpag2iEAAAAASUVORK5CYII="; env.resources["resources/vview-icon.png"] = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMAAAADACAIAAADdvvtQAAAACXBIWXMAAAsTAAALEwEAmpwYAAAGsmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcDpDcmVhdGVEYXRlPSIyMDIxLTEwLTE2VDA2OjQwOjI2LTA1OjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyMS0xMi0xMVQyMzo1MDowOS0wNjowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMS0xMi0xMVQyMzo1MDowOS0wNjowMCIgZGM6Zm9ybWF0PSJpbWFnZS9wbmciIHBob3Rvc2hvcDpDb2xvck1vZGU9IjMiIHBob3Rvc2hvcDpJQ0NQcm9maWxlPSJzUkdCIElFQzYxOTY2LTIuMSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDo4YjNhNjQ1Zi0xNjZjLWUyNDItODA0ZC0yMTUyNGYwY2IyMjYiIHhtcE1NOkRvY3VtZW50SUQ9ImFkb2JlOmRvY2lkOnBob3Rvc2hvcDo5YzAyOTMyZi05ZDY2LWEzNGItOTRiMy05YjRlMmVmMTdlYzkiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpjMmNkZjUwMC0wZTAzLWI0NDQtYjEwNS0yZTMzNGQ1YTlmY2QiPiA8cGhvdG9zaG9wOlRleHRMYXllcnM+IDxyZGY6QmFnPiA8cmRmOmxpIHBob3Rvc2hvcDpMYXllck5hbWU9IlYiIHBob3Rvc2hvcDpMYXllclRleHQ9IlYiLz4gPHJkZjpsaSBwaG90b3Nob3A6TGF5ZXJOYW1lPSJWIiBwaG90b3Nob3A6TGF5ZXJUZXh0PSJWIi8+IDwvcmRmOkJhZz4gPC9waG90b3Nob3A6VGV4dExheWVycz4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDpjMmNkZjUwMC0wZTAzLWI0NDQtYjEwNS0yZTMzNGQ1YTlmY2QiIHN0RXZ0OndoZW49IjIwMjEtMTAtMTZUMDY6NDA6MjYtMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE5IChXaW5kb3dzKSIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6OGIzYTY0NWYtMTY2Yy1lMjQyLTgwNGQtMjE1MjRmMGNiMjI2IiBzdEV2dDp3aGVuPSIyMDIxLTEyLTExVDIzOjUwOjA5LTA2OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+fbDRPQAADClJREFUeJztnWtwVdUVx//7XN40vBIg4WXA8goQQMUyvBpBHgIjVltHpLVoQcWOY8c+hj6m0w+dsbWjM8iUWEKrjcIEq6gFKhG1LYRg5ZkACXkQIAQSCAGEAAmQc/rhttdwc5Pctfd9nL3P+n1kErLvOb+7z9pnrb22wJobYBhZrHgPgNEbFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRokO8B8B8RWqCmJ4iRvQSO6vtvGrn2q14DygMWCBX8NMJ1pIR1vhE8f9/sBqakHnYfjG/KZ7DCgPBTTbjy4DuInOG9WBq6Fji0ypnzpZbthPjQRHgGCiejO4tcmb7WrMHwKxB4r25vlgOiQoLFE8yZ/imp4i2f+ahodYv73bvbXLvyIwnK8P3zQHt2OPnt/f6MsL7ydjDAsWHpaOsZaMJF/9nE136IGOB4sOP0mlX/oEh4rmxbrxZbhyT8ayc2HzFHi5LhrvxZrlxTGaT0BHPjJG57FOShc99gRALFGteSLdSEyRF6Oy+QIgFiimWwGNfl7zm+2vdmNxggWLK8+OsMX0kp5/1ZXZkBxMRWKCY8rhsILypwn61gAXyNg+mWvf2k5l+PjxuP5Lr0qyqZtn4CUlieoro3RmV9dh5xjl22cVpxhZMay9rEZKN5fZj211qD3QRaGqyWJhqLRttJXW57d8L65zMI/brR9w4twcxsLt4Oo083+eU24tdbA+0EGjlROulyaHXr+mJInOGLz1RPLfD1VcZwHdHiJ6daL+y5aTjcnvgfoHen+d7aGg7X9wVY6wTV5yXD7h6HnpkGG36aWzCj11fTQaXB9G5Czu0a4+f30/2ufNNv59J/cQkYvj86z1NpZc0iPDce9GzZ/nmDCZc9BWuzDX6WXgHbWyFdW6fUAO49KI/k2Z9bwRtbFOTxcqJLv04Dw+jTT9vlephD1wr0A8otTIBfjPJN6KX6/KNGQPEWMrb54rLTqYO60o/bhSoiw/UiMFPZx+eHOW6TzRrEG1I68ucqzejNJbI47rLDSBNNlsEYOVES+59XfSgPr8+OK7N9AN3ClRVr7T6ePROF32oqckirTdBoPwaZ3+tBouvAC661gHOXUeJwgr2+XHWzIFumYTmDKY+v3SafuBOgQBsqlD6FkrX3EScRUMJKl+7hRwWKCJsPal0HZenWfOGxH8SGtVLkGqfc8rtC43RG05UcKlAu2qcn3+u9CKf+u4uGiy4gyZxXrVO0Y+f+F/l1vjdAVtlPfLDsVaY2/aix8PE/Jde6y8/7hUIwLe2NeWUy1/T+E5CPTphSjJt/XVRt+cXXC4QgMXbm/52TNKhn0yw7ukbt0loAVHfd2Q/Znxxu0AAXsy3pb+a1IdIBJlGmX4AbD2pXwAELQSqqnd+9YVkQP1CunVnj/hMQqRlYEGdU/4lCxQ11hy25R5k3TrgiZFx+IzjE8Uwirh/P6GlPdBFIAB/LpYMEZ4YKRI6RnYs7UOqZAKw84yWARA0Eij3lPOHgzJXOTVBxL7WLMxCSj/nG7C9imeg6LOu2G6UioUWxzaz0cmiLeA/qtR1+oFeApVecl7aL2PQhCTxrFRDDDnmDaH9LR1fQAfQSSAArx2y5Yo9vh/DUJpakKTpAt6PZgJdbMQfD8tM+JP7i2/Hqk5oPmUBv6/WOX2VBYohrxTYR6WqheYSV0ZyDO8pSP03Np/QOACCjgLdtLGuSOaiLxtttdtTVx2qpv8+o/H0Ax0FAvBKgb1Pqu5zfvTTq6QA6HwD/sUCxQW5nVNPp1kDu0d3EppLqWHNPaX38wv6CrSqUGYS6tMZS0dFUaCZA0WvzoSf13oB70dXgSA7CUV1zwY1xso9xQLFD7lJKD1RyG17DYeFrR+b0pLCOue4Vg2yQqKxQJCdhJ6Kzu7Vnp1Aql/brG0Gvjl6C7Sq0D54nnwbpiSLGVEol55N3AKWV6N9BA3dBQKwQWoj1TzizQ4Hagnix/oHQDBAoNWH7DJ6Ld/yNCulW4RHQqoB2lXjuPkcwvDRXqCGJmSXkCehpC4R7uMxrIcYTdkD/66eJfQt0V4gAJlH7DP0fOSjES0Sun+Q5xbwfkwQqK4BWcXk+zE+USyN3CREymCUfekUX2SB3MRfS+x6elMm0pmBbTOL0g9kuynTD4wR6PhlZ/UhclQxNVlEZOfhPX3FAEqKLa+GBXIf70ttLF9EqX5vjZnEAOhj/XOoAcwRaM85R6JY8fHhQv0Ut0WUDMbeWqeuQfUvugdzBAKwjb69YVgPsVwtEvIJ2h6MLZqXIAZhlEBbTjr59PBiCbEhdRCziM+vnfqXcDTHKIEArKNvYJ3cX1AlaM60FNo11L0EMQjTBHrjqH2Anl69n9jKuTkLKHswjMlgBDBNIAASPameHGX16yrztxK74C7KiwBjMhgBDBRobZF9irj5sH9XyL2Vvm8g7bf03QPfGgYKdKkR2SX01JhUqSuphOPYZefIBRZIB96tID8p7u4rJNqZkTqam1EAFISZAh08L5PZoFaEJXfDOEobaAP2YLTETIEAfHaafLdWjLWG9yQIkTGAGgCZFkHDYIE+OG7vOUdzqIsPi4cTBCKVcOyvdWqvk4ajB8YKBKn1/HcoofR9lABoi849XNrAZIGyiuyTV2i3bWyfcKvMkruBdI5TXrWBzy+YLdCVm3i7lPy9D7PKjPoGSCIm0wKTBQKwmX7qT5hVZqQlW36N02SmP6YL9J+zzi56fj6cKjNS/lWu2E0LDBcIwFp6N6p2q8wGfU2MpBwPberzC14QKLvELqij3b92q8wyKDujq+o1OwaVhPkCAXiHvp5vu8qM9AboE+MSqM3xhEDZpeSjuNquMiPNQCbtwWiJJwSqqnf+RI+EWqsy698VpADIpF1gLfGEQAA+o+ehWqsym05JgR2+4FRKdUbXBa8ItL3K2X02MlVmpDdA2ypNtgfeEQjA2/R2ZiGrzEhn+ZodAMFTAmUetkuJLe5bVpkldMSEJIJAn5pYwtEcDwnkADnl5Pkg6IFF6sO6t9aRaPmgFx4SCMDGcpuakwqqMiPtAss1PQCC1wQqukjePx9UZTab0sfOjDaabeMtgQDsoG8MDYTSXTvQGvnqfpBKOHhOoPcq7L3EzNSYPmJ5mgXgG/1oAdD1W7Sx6YjnBAKQQ+8MvHSkBWIKzAsBELwpUFYxudTV35v8AcphqF4IgOBNgS7fkCl1nTfYIvUB+qe5NUDN8aJAADbRSwSfovSh+uKcI3dCuXZ4VKD9tc7rR2gO9ae07/jIGwEQPCsQotzoKd8bARC8LNDGcsmDV8Nht+k51ADeFQjARnqpazgU1DlXTE+BBfC0QFnF5FZU4eCd6QceF0iuFVW7GNnGpTU8LRCAD6PQtZla+qg1XhdozzlHYudhG1RfQ4X+R+mGj9cFglR+vg28s4D3wwJhfZkdwZ2jn3vp+QUWyM/GyLVvlmhzrjUsEACsK7JP0w/NDInEMeRawwIBwIVGvHk0Aje+qt6oo5zCgQX6HxKtpVvitecXWKAAB887r9FbSwf/J3URGYtOsEBfod6HxWsBEFig5mw+YSsuwvcSO1MbAAt0G28clX+KVdYb3ogjJCzQbawtki8SiuwbbV1ggYJ5i97Ew4+nkvABWKBgVhWSm3ICOHrJ+YvC409fWKAQbKDvPFx9yL7pRX9YoFC8fIC2/XlDmb2G2LPBGFig0GSFXST0j0pnySfe2AMWChYoNGuL7OyS9h1aV2wv2OqBFgqt0yHeA4gYloDjIIILoWd3NCV1FfNbORZ+91lnbZH9picD5+YIrLkR7zGo4r/DUVpD/+Iua1qKNT1FNDSh+qqzr9bZWe3k1TjUdoumYoJATBzhGIhRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCX+C3RktH6WMZYaAAAAAElFTkSuQmCC"; env.resources["resources/zoom-actual.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/zoom-full.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/zoom-minus.svg"] = loadBlob("image/svg+xml", ` `); env.resources["resources/zoom-plus.svg"] = loadBlob("image/svg+xml", ` `); env.startup = `/\x2f Early setup. If we're running in a user script, this is the entry point for regular /\x2f app code that isn't running in the script sandbox, where we interact with the page /\x2f normally and don't need to worry about things like unsafeWindow. /\x2f /\x2f If we're running on Pixiv, this checks if we want to be active, and handles adding the /\x2f the "start ppixiv" button. If the app is running, it starts it. This also handles /\x2f shutting down Pixiv's scripts before we get started. /\x2f /\x2f For vview, this is the main entry point. class AppStartup { constructor({env, rootUrl}) { this.initialSetup({env, rootUrl}); } /\x2f We can either be given a startup environment, or a server URL where we can fetch one. /\x2f If we're running in a user script then the environment is packaged into the script, and /\x2f if we're running on vview or a user script development environment we'll have a URL. /\x2f We'll always be given one or the other. This lets us skip the extra stuff in bootstrap.js /\x2f when we're running natively, and just start directly. async initialSetup({env, rootUrl}) { /\x2f Set a dark background color early to try to prevent flashbangs if the page is rendered /\x2f before we get styles set up. document.documentElement.style.backgroundColor = "#000"; let native = location.hostname != "pixiv.net" && location.hostname != "www.pixiv.net"; let ios = navigator.platform.indexOf('iPhone') != -1 || navigator.platform.indexOf('iPad') != -1; let android = navigator.userAgent.indexOf('Android') != -1; let mobile = ios || android; /\x2f If we weren't given an environment, fetch it from rootUrl. if(env == null) { if(rootUrl == null) { alert("Unexpected error: no environment or root URL"); return; } let url = new URL("/vview/init.js", rootUrl); let request = await fetch(url); env = await request.json(); } if(window.ppixiv) { /\x2f Make sure that we're not loaded more than once. This can happen if we're installed in /\x2f multiple script managers, or if the release and debug versions are enabled simultaneously. console.error("ppixiv has been loaded twice. Is it loaded in multiple script managers?"); return; } /\x2f Set up the global object. window.ppixiv = { resources: env.resources, version: env.version, native, mobile, ios, android, }; console.log(\`\${native? "vview":"ppixiv"} setup\`); console.log("Browser:", navigator.userAgent); /\x2f "Stay" for iOS leaves a