\${ helpers.create_box_link({label: "All", link: "?mode=all#ppixiv", popup: "Show all works", data_type: "all" }) }
\${ helpers.create_box_link({label: "All ages", link: "?mode=safe#ppixiv", popup: "All ages", data_type: "safe" }) }
\${ helpers.create_box_link({label: "R18", link: "?mode=r18#ppixiv", popup: "R18", data_type: "r18", classes: ["r18"] }) }
\${ helpers.create_box_link({label: "Illustrations", popup: "Show illustrations", data_type: "new-illust-type-illust" }) }
\${ helpers.create_box_link({label: "Manga", popup: "Show manga only", data_type: "new-illust-type-manga" }) }
\${ helpers.create_box_link({label: "All ages", popup: "Show all-ages works", data_type: "new-illust-ages-all" }) }
\${ helpers.create_box_link({label: "R18", popup: "Show R18 works", data_type: "new-illust-ages-r18" }) }
\${ helpers.create_box_link({label: "Next day", popup: "Show the next day", data_type: "new-illust-type-illust", classes: ["nav-tomorrow"] }) }
\${ helpers.create_box_link({label: "Previous day", popup: "Show the previous day", data_type: "new-illust-type-illust", classes: ["nav-yesterday"] }) }
\${ helpers.create_box_link({label: "All", popup: "Show all works", data_type: "content-all" }) }
\${ helpers.create_box_link({label: "Illustrations", popup: "Show illustrations only", data_type: "content-illust" }) }
\${ helpers.create_box_link({label: "Animations", popup: "Show animations only", data_type: "content-ugoira" }) }
\${ helpers.create_box_link({label: "Manga", popup: "Show manga only", data_type: "content-manga" }) }
\${ helpers.create_box_link({label: "Daily", popup: "Daily rankings", data_type: "mode-daily" }) }
\${ helpers.create_box_link({label: "R18", popup: "Show R18 works (daily only)", data_type: "mode-daily-r18", classes: ["r18"] }) }
\${ helpers.create_box_link({label: "R18G", popup: "Show R18G works (weekly only)", data_type: "mode-r18g", classes: ["r18g"] }) }
\${ helpers.create_box_link({label: "Weekly", popup: "Weekly rankings", data_type: "mode-weekly" }) }
\${ helpers.create_box_link({label: "Monthly", popup: "Monthly rankings", data_type: "mode-monthly" }) }
\${ helpers.create_box_link({label: "Rookie", popup: "Rookie rankings", data_type: "mode-rookie" }) }
\${ helpers.create_box_link({label: "Original", popup: "Original rankings", data_type: "mode-original" }) }
\${ helpers.create_box_link({label: "Male", popup: "Popular with men", data_type: "mode-male" }) }
\${ helpers.create_box_link({label: "Female", popup: "Popular with women", data_type: "mode-female" }) }
\${ helpers.create_box_link({label: "Clear", popup: "Clear recent history", data_type: "clear-recents" }) }
\${ helpers.create_box_link({label: "Public", popup: "Show publically followed users", data_type: "public-follows" }) }
\${ helpers.create_box_link({label: "Private", popup: "Show privately followed users", data_type: "private-follows" }) }
\${ helpers.create_box_link({label: "All tags", popup: "Follow tags", icon: "bookmark", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({label: "All", popup: "Show all works", data_type: "bookmarks-new-illust-all", classes: ["r18"] }) }
\${ helpers.create_box_link({label: "R18", popup: "Show R18 works", data_type: "bookmarks-new-illust-ages-r18", classes: ["r18"] }) }
\${ helpers.create_box_link({label: "All tags", popup: "Follow tags", icon: "bookmark", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({label: "Related tags", icon: "bookmark", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({label: "Ages", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({label: "Sort", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({label: "Type", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({label: "Search mode", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({label: "Image size", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({label: "Aspect ratio", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({label: "Bookmarks", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({label: "Time", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({label: "Reset", popup: "Clear all search options", classes: ["reset-search"] }) }
\`
});
}
}
// The search UI.
ppixiv.screen_search = class extends ppixiv.screen
{
constructor(options)
{
super(options);
this.scroll_container = this.container.querySelector(".search-results");
this.expanded_media_ids = new Map();
window.addEventListener("thumbnailsloaded", this.thumbs_loaded);
window.addEventListener("focus", this.visible_thumbs_changed);
this.container.addEventListener("wheel", this.onwheel, { passive: false });
// this.container.addEventListener("mousemove", this.onmousemove);
image_data.singleton().user_modified_callbacks.register(this.refresh_ui);
// When a bookmark is modified, refresh the heart icon.
image_data.singleton().illust_modified_callbacks.register(this.refresh_thumbnail);
this.container.addEventListener("load", (e) => {
if(e.target.classList.contains("thumb"))
this.thumb_image_load_finished(e.target.closest(".thumbnail-box"), { cause: "onload" });
}, { capture: true } );
new thumbnail_ui({
parent: this,
container: this.container.querySelector(".thumbnail-ui-box-container"),
});
this.create_main_search_menu();
// Create the avatar widget shown on the artist data source.
this.avatar_container = this.container.querySelector(".avatar-container");
this.avatar_widget = new avatar_widget({
container: this.avatar_container,
changed_callback: this.data_source_updated,
big: true,
mode: "dropdown",
});
this.scroll_container.addEventListener("scroll", (e) => {
this.schedule_store_scroll_position();
}, {
passive: true,
});
// Create the tag widget used by the search data source.
this.tag_widget = new tag_widget({
contents: this.container.querySelector(".related-tag-list"),
});
// Don't scroll thumbnails when scrolling tag dropdowns.
// FIXME: This works on member-tags-box, but not reliably on search-tags-box, even though
// they seem like the same thing.
this.container.querySelector(".member-tags-box .post-tag-list").addEventListener("scroll", function(e) { e.stopPropagation(); }, true);
this.container.querySelector(".search-tags-box .related-tag-list").addEventListener("scroll", function(e) { e.stopPropagation(); }, true);
this.container.querySelector(".bookmark-tags-box .bookmark-tag-list").addEventListener("scroll", function(e) { e.stopPropagation(); }, true);
this.container.querySelector(".local-bookmark-tags-box .local-bookmark-tag-list").addEventListener("scroll", function(e) { e.stopPropagation(); }, true);
// Set up hover popups.
dropdown_menu_opener.create_handlers(this.container);
// As an optimization, start loading image info on mousedown. We don't navigate until click,
// but this lets us start loading image info a bit earlier.
this.container.querySelector(".thumbnails").addEventListener("mousedown", async (e) => {
if(e.button != 0)
return;
var a = e.target.closest("a.thumbnail-link");
if(a == null)
return;
if(a.dataset.mediaId == null)
return;
// Only do this for illustrations.
let {type} = helpers.parse_media_id(a.dataset.mediaId);
if(type != "illust")
return;
await image_data.singleton().get_media_info(a.dataset.mediaId);
}, true);
this.container.querySelector(".refresh-search-button").addEventListener("click", this.refresh_search);
this.container.querySelector(".whats-new-button").addEventListener("click", this.whats_new);
this.container.querySelector(".thumbnails").addEventListener("click", this.thumbnail_onclick);
this.container.querySelector(".expand-manga-posts").addEventListener("click", (e) => {
this.toggle_expanding_media_ids_by_default();
});
// Handle quick view.
new ppixiv.pointer_listener({
element: this.container.querySelector(".thumbnails"),
button_mask: 0b1,
callback: (e) => {
if(!e.pressed)
return;
let a = e.target.closest("A");
if(a == null)
return;
if(!settings.get("quick_view"))
return;
// Activating on press would probably break navigation on touchpads, so only do
// this for mouse events.
if(e.pointerType != "mouse")
return;
let { media_id } = main_controller.singleton.get_illust_at_element(e.target);
if(media_id == null)
return;
// Don't stopPropagation. We want the illustration view to see the press too.
e.preventDefault();
// e.stopImmediatePropagation();
main_controller.singleton.show_media(media_id, { add_to_history: true });
},
});
// Clear recent illusts:
this.container.querySelector("[data-type='clear-recents']").addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
await ppixiv.recently_seen_illusts.get().clear();
this.refresh_search();
});
this.container.querySelector(".preferences-button").addEventListener("click", (e) => {
new ppixiv.settings_dialog({ container: document.body });
});
settings.register_change_callback("thumbnail-size", () => { this.refresh_images(); });
settings.register_change_callback("manga-thumbnail-size", () => { this.refresh_images(); });
settings.register_change_callback("theme", this.update_from_settings);
settings.register_change_callback("disable_thumbnail_zooming", this.update_from_settings);
settings.register_change_callback("disable_thumbnail_panning", this.update_from_settings);
settings.register_change_callback("ui-on-hover", this.update_from_settings);
settings.register_change_callback("no-hide-cursor", this.update_from_settings);
settings.register_change_callback("no_recent_history", this.update_from_settings);
settings.register_change_callback("expand_manga_thumbnails", this.update_from_settings);
muting.singleton.addEventListener("mutes-changed", this.refresh_after_mute_change);
// Zoom the thumbnails on ctrl-mousewheel:
this.container.addEventListener("wheel", (e) => {
if(!e.ctrlKey)
return;
e.preventDefault();
e.stopImmediatePropagation();
let manga_view = this.data_source?.name == "manga";
settings.adjust_zoom(manga_view? "manga-thumbnail-size":"thumbnail-size", e.deltaY > 0);
}, { passive: false });
this.container.addEventListener("keydown", (e) => {
let zoom = helpers.is_zoom_hotkey(e);
if(zoom != null)
{
e.preventDefault();
e.stopImmediatePropagation();
let manga_view = this.data_source?.name == "manga";
settings.adjust_zoom(manga_view? "manga-thumbnail-size":"thumbnail-size", zoom < 0);
}
});
// Create the tag dropdown for the search page input.
new tag_search_box_widget({ contents: this.container.querySelector(".tag-search-box") });
// Create the tag dropdown for the search input in the menu dropdown.
new tag_search_box_widget({ contents: this.container.querySelector(".navigation-search-box") });
// The search history dropdown for local searches.
new local_search_box_widget({ contents: this.container.querySelector(".local-tag-search-box") });
new close_search_widget({
parent: this,
container: this.container.querySelector(".local-navigation-box"),
});
this.local_nav_widget = new ppixiv.local_navigation_widget({
parent: this,
container: this.container.querySelector(".local-navigation-box"),
});
this.container.querySelector(".copy-local-path").addEventListener("click", (e) => {
this.data_source.copy_link();
});
// Handle submitting searches on the user search page.
this.container.querySelector(".user-search-box .search-submit-button").addEventListener("click", this.submit_user_search);
helpers.input_handler(this.container.querySelector(".user-search-box input.search-users"), this.submit_user_search);
// Create IntersectionObservers for thumbs that are completely onscreen, nearly onscreen (should
// be preloaded), and farther off (but not so far they should be unloaded).
this.intersection_observers = [];
this.intersection_observers.push(new IntersectionObserver((entries) => {
for(let entry of entries)
helpers.set_dataset(entry.target.dataset, "fullyOnScreen", entry.isIntersecting);
this.load_data_source_page();
this.first_visible_thumbs_changed();
}, {
root: this.scroll_container,
threshold: 1,
}));
this.intersection_observers.push(new IntersectionObserver((entries) => {
let any_changed = false;
for(let entry of entries)
{
// Ignore special entries,
if(entry.target.dataset.special)
continue;
helpers.set_dataset(entry.target.dataset, "nearby", entry.isIntersecting);
any_changed = true;
}
// If no actual thumbnails changed, don't refresh. We don't want to trigger a refresh
// from the special buttons being removed and added.
if(!any_changed)
return;
// Set up any thumbs that just came nearby, and see if we need to load more search results.
this.refresh_images();
this.set_visible_thumbs();
this.load_data_source_page();
}, {
root: this.scroll_container,
// This margin determines how far in advance we load the next page of results.
rootMargin: "50%",
}));
this.intersection_observers.push(new IntersectionObserver((entries) => {
for(let entry of entries)
helpers.set_dataset(entry.target.dataset, "visible", entry.isIntersecting);
this.visible_thumbs_changed();
}, {
root: this.scroll_container,
rootMargin: "0%",
}));
/*
* Add a slight delay before hiding the UI. This allows opening the UI by swiping past the top
* of the window, without it disappearing as soon as the mouse leaves the window. This doesn't
* affect opening the UI.
*
* We're actually handling the manga UI's top-ui-box here too.
*/
for(let box of document.querySelectorAll(".top-ui-box"))
new hover_with_delay(box, 0, 0.25);
this.update_from_settings();
this.refresh_images();
this.load_data_source_page();
this.refresh_whats_new_button();
}
create_main_search_menu()
{
let option_box = this.container.querySelector(".main-search-menu");
this.menu_options = [];
let options = [
{ label: "New works", url: "/new_illust.php#ppixiv" },
{ label: "New works by following", url: "/bookmark_new_illust.php#ppixiv" },
[
{ label: "Bookmarks", url: \`/users/\${window.global_data.user_id}/bookmarks/artworks#ppixiv\` },
{ label: "all", url: \`/users/\${window.global_data.user_id}/bookmarks/artworks#ppixiv\` },
{ label: "public", url: \`/users/\${window.global_data.user_id}/bookmarks/artworks#ppixiv?show-all=0\` },
{ label: "private", url: \`/users/\${window.global_data.user_id}/bookmarks/artworks?rest=hide#ppixiv?show-all=0\` },
],
[
{ label: "Followed users", url: \`/users/\${window.global_data.user_id}/following#ppixiv\` },
{ label: "public", url: \`/users/\${window.global_data.user_id}/following#ppixiv\` },
{ label: "private", url: \`/users/\${window.global_data.user_id}/following?rest=hide#ppixiv\` },
],
{ label: "Rankings", url: "/ranking.php#ppixiv" },
{ label: "Recommended works", url: "/discovery#ppixiv" },
{ label: "Recommended users", url: "/discovery/users#ppixiv" },
{ label: "Completed requests", url: "/request/complete/illust#ppixiv" },
{ label: "Search users", url: "/search_user.php#ppixiv" },
{ label: "Recent history", url: "/history.php#ppixiv", classes: ["recent-history-link"] },
{ label: "Local search", url: \`\${local_api.path}#ppixiv/\`, local: true, onclick: local_api.show_local_search },
];
let create_option = (option) => {
let button = new menu_option_button({
container: option_box,
parent: this,
...option
})
// Hide the local search menu option if it's not enabled.
if(option.local && !local_api.is_enabled())
button.container.hidden = true;
return button;
};
for(let option of options)
{
if(Array.isArray(option))
{
let items = [];
for(let suboption of option)
items.push(create_option(suboption));
new menu_option_row({
container: option_box,
parent: this,
items: items,
});
}
else
this.menu_options.push(create_option(option));
}
// Move the tag search box back to the bottom.
let search = option_box.querySelector(".navigation-search-box");
search.remove();
option_box.insertAdjacentElement("beforeend", search);
}
get_thumbnail_for_media_id(media_id)
{
return this.container.querySelector(\`[data-id='\${helpers.escape_selector(media_id)}']\`);
}
get_first_visible_thumb()
{
// Find the first thumb that's fully onscreen. Ignore elements not specific to a page (load previous results).
return this.container.querySelector(\`.thumbnails > [data-id][data-fully-on-screen][data-search-page]\`);
}
// This is called as the user scrolls and different thumbs are fully onscreen,
// to update the page URL.
first_visible_thumbs_changed()
{
// Find the first thumb that's fully onscreen. Ignore elements not specific to a page (load previous results).
let first_thumb = this.get_first_visible_thumb();
if(!first_thumb)
return;
// If the data source supports a start page, update the page number in the URL to reflect
// the first visible thumb.
if(this.data_source == null || !this.data_source.supports_start_page || first_thumb.dataset.searchPage == null)
return;
let args = helpers.args.location;
this.data_source.set_start_page(args, first_thumb.dataset.searchPage);
helpers.set_page_url(args, false, "viewing-page", { send_popstate: false });
}
// The thumbs actually visible onscreen have changed, or the window has gained focus.
// Store recently viewed thumbs.
visible_thumbs_changed = () =>
{
// Don't add recent illusts if we're viewing recent illusts.
if(this.data_source && this.data_source.name == "recent")
return;
let visible_media_ids = [];
for(let element of this.container.querySelectorAll(\`.thumbnails > [data-id][data-visible]:not([data-special])\`))
{
let { type, id } = helpers.parse_media_id(element.dataset.id);
if(type != "illust")
continue;
visible_media_ids.push(element.dataset.id);
}
ppixiv.recently_seen_illusts.get().add_illusts(visible_media_ids);
}
refresh_search = () =>
{
main_controller.singleton.refresh_current_data_source();
}
// Set or clear the updates class on the "what's new" button.
refresh_whats_new_button()
{
let last_viewed_version = settings.get("whats-new-last-viewed-version", 0);
// This was stored as a string before, since it came from GM_info.script.version. Make
// sure it's an integer.
last_viewed_version = parseInt(last_viewed_version);
let new_updates = last_viewed_version < whats_new.latest_interesting_history_revision();
helpers.set_class(this.container.querySelector(".whats-new-button"), "updates", new_updates);
}
whats_new = () =>
{
settings.set("whats-new-last-viewed-version", whats_new.latest_history_revision());
this.refresh_whats_new_button();
new whats_new({ container: document.body });
}
/* This scrolls the thumbnail when you hover over it. It's sort of neat, but it's pretty
* choppy, and doesn't transition smoothly when the mouse first hovers over the thumbnail,
* causing it to pop to a new location.
onmousemove = (e) =>
{
var thumb = e.target.closest(".thumbnail-box a");
if(thumb == null)
return;
var bounds = thumb.getBoundingClientRect();
var x = e.clientX - bounds.left;
var y = e.clientY - bounds.top;
x = 100 * x / thumb.offsetWidth;
y = 100 * y / thumb.offsetHeight;
var img = thumb.querySelector("img.thumb");
img.style.objectPosition = x + "% " + y + "%";
}
*/
onwheel = (e) =>
{
// Stop event propagation so we don't change images on any viewer underneath the thumbs.
e.stopPropagation();
};
initial_refresh_ui()
{
if(this.data_source != null)
{
var ui_box = this.container.querySelector(".thumbnail-ui-box");
this.data_source.initial_refresh_thumbnail_ui(ui_box, this);
}
this.load_expanded_media_ids();
}
set_data_source(data_source)
{
if(this.data_source == data_source)
return;
// Remove listeners from the old data source.
if(this.data_source != null)
this.data_source.remove_update_listener(this.data_source_updated);
// Clear the view when the data source changes. If we leave old thumbs in the list,
// it confuses things if we change the sort and refresh_thumbs tries to load thumbs
// based on what's already loaded.
let ul = this.container.querySelector(".thumbnails");
while(ul.firstElementChild != null)
{
let node = ul.firstElementChild;
node.remove();
// We should be able to just remove the element and get a callback that it's no longer visible.
// This works in Chrome since IntersectionObserver uses a weak ref, but Firefox is stupid and leaks
// the node.
for(let observer of this.intersection_observers)
observer.unobserve(node);
}
this.data_source = data_source;
// Cancel any async scroll restoration if the data source changes.
this.cancel_restore_scroll_pos();
if(this.data_source == null)
return;
// If we disabled loading more pages earlier, reenable it.
this.disable_loading_more_pages = false;
// Disable the avatar widget unless the data source enables it.
this.avatar_container.hidden = true;
this.avatar_widget.set_user_id(null);
// Listen to the data source loading new pages, so we can refresh the list.
this.data_source.add_update_listener(this.data_source_updated);
};
refresh_ui = () =>
{
if(!this.active)
return;
var element_displaying = this.container.querySelector(".displaying");
element_displaying.hidden = this.data_source.get_displaying_text == null;
if(this.data_source.get_displaying_text != null)
{
// get_displaying_text can either be a string or an element.
let text = this.data_source.get_displaying_text();
helpers.remove_elements(element_displaying);
if(typeof text == "string")
element_displaying.innerText = text;
else if(text instanceof HTMLElement)
{
helpers.remove_elements(element_displaying);
element_displaying.appendChild(text);
}
}
this.data_source.set_page_icon();
helpers.set_page_title(this.data_source.page_title || "Loading...");
var ui_box = this.container.querySelector(".thumbnail-ui-box");
this.data_source.refresh_thumbnail_ui(ui_box, this);
this.refresh_slideshow_button();
this.refresh_ui_for_user_id();
this.refresh_expand_manga_posts_button();
};
// Return the user ID we're viewing, or null if we're not viewing anything specific to a user.
get viewing_user_id()
{
if(this.data_source == null)
return null;
return this.data_source.viewing_user_id;
}
// If the data source has an associated artist, return the "user:ID" for the user, so
// when we navigate back to an earlier search, pulse_thumbnail will know which user to
// flash.
get displayed_media_id()
{
if(this.data_source == null)
return super.displayed_media_id;
let user_id = this.data_source.viewing_user_id;
if(user_id != null)
return "user:" + user_id;
let folder_id = this.data_source.viewing_folder;
if(folder_id != null)
return folder_id;
return super.displayed_media_id;
}
// Call refresh_ui_for_user_info with the user_info for the user we're viewing,
// if the user ID has changed.
async refresh_ui_for_user_id()
{
// If we're viewing ourself (our own bookmarks page), hide the user-related UI.
var initial_user_id = this.viewing_user_id;
var user_id = initial_user_id == window.global_data.user_id? null:initial_user_id;
var user_info = await image_data.singleton().get_user_info_full(user_id);
// Stop if the user ID changed since we started this request, or if we're no longer active.
if(this.viewing_user_id != initial_user_id || !this.active)
return;
// Make a list of links to add to the top corner.
//
// If we reach our limit for the icons we can fit, we'll cut off at the end, so put
// higher-priority links earlier.
let extra_links = [];
if(user_info != null)
{
extra_links.push({
url: new URL(\`/messages.php?receiver_id=\${user_info.userId}\`, ppixiv.location),
type: "contact-link",
label: "Send a message",
});
extra_links.push({
url: new URL(\`/users/\${user_info.userId}/following#ppixiv\`, ppixiv.location),
type: "following-link",
label: \`View \${user_info.name}'s followed users\`,
});
extra_links.push({
url: new URL(\`/users/\${user_info.userId}/bookmarks/artworks#ppixiv\`, ppixiv.location),
type: "bookmarks-link",
label: user_info? \`View \${user_info.name}'s bookmarks\`:\`View bookmarks\`,
});
extra_links.push({
url: new URL(\`/discovery/users#ppixiv?user_id=\${user_info.userId}\`, ppixiv.location),
type: "similar-artists",
label: "Similar artists",
});
}
// Set the pawoo link.
let pawoo_url = user_info?.social?.pawoo?.url;
if(pawoo_url != null)
{
extra_links.push({
url: pawoo_url,
type: "pawoo-icon",
label: "Pawoo",
});
}
// Add the twitter link if there's one in the profile.
let twitter_url = user_info?.social?.twitter?.url;
if(twitter_url != null)
{
extra_links.push({
url: twitter_url,
type: "twitter-icon",
});
}
// Set the circle.ms link.
let circlems_url = user_info?.social?.circlems?.url;
if(circlems_url != null)
{
extra_links.push({
url: circlems_url,
type: "circlems-icon",
label: "Circle.ms",
});
}
// Set the webpage link.
//
// If the webpage link is on a known site, disable the webpage link and add this to the
// generic links list, so it'll use the specialized icon.
let webpage_url = user_info?.webpage;
if(webpage_url != null)
{
let type = this.find_link_image_type(webpage_url);
extra_links.push({
url: webpage_url,
type: type || "webpage-link",
label: "Webpage",
});
}
// Find any other links in the user's profile text.
if(user_info != null)
{
let div = document.createElement("div");
div.innerHTML = user_info.commentHtml;
let limit = 4;
for(let link of div.querySelectorAll("a"))
{
extra_links.push({url: helpers.fix_pixiv_link(link.href)});
// Limit these in case people have a ton of links in their profile.
limit--;
if(limit == 0)
break;
}
}
// Let the data source add more links. For Fanbox links this is usually delayed
// since it requires an extra API call, so put this at the end to prevent the other
// buttons from shifting around.
if(this.data_source != null)
this.data_source.add_extra_links(extra_links);
// Remove any extra buttons that we added earlier.
let row = this.container.querySelector(".button-row.user-links");
for(let div of row.querySelectorAll(".extra-profile-link-button"))
div.remove();
// Map from link types to icons:
let link_types = {
["default-icon"]: "resources/link-icon.svg",
["shopping-cart"]: "resources/shopping-cart.svg",
["twitter-icon"]: "resources/icon-twitter.svg",
["fanbox-icon"]: "resources/icon-fanbox.svg",
["booth-icon"]: "resources/icon-booth.svg",
["webpage-link"]: "resources/icon-webpage.svg",
["pawoo-icon"]: "resources/icon-pawoo.svg",
["circlems-icon"]: "resources/icon-circlems.svg",
["twitch-icon"]: "resources/logo-twitch.svg",
["contact-link"]: "resources/send-message.svg",
["following-link"]: "resources/followed-users-eye.svg",
["bookmarks-link"]: "resources/icon-bookmarks.svg",
["similar-artists"]: "resources/related-illusts.svg",
};
let seen_links = {};
for(let {url, label, type} of extra_links)
{
// Don't add the same link twice if it's in more than one place.
if(seen_links[url])
continue;
seen_links[url] = true;
try {
url = new URL(url);
} catch(e) {
console.log("Couldn't parse profile URL:", url);
continue;
}
// Guess the link type if one wasn't supplied.
if(type == null)
type = this.find_link_image_type(url);
if(type == null)
type = "default-icon";
let entry = this.create_template({name: "extra-link", html: \`
\`});
let image_name = link_types[type];
let icon = helpers.create_ppixiv_inline(image_name);
icon.classList.add(type);
entry.querySelector(".extra-link").appendChild(icon);
let a = entry.querySelector(".extra-link");
a.href = url;
// If this is a Twitter link, parse out the ID. We do this here so this works
// both for links in the profile text and the profile itself.
if(type == "twitter-icon")
{
let parts = url.pathname.split("/");
label = parts.length > 1? ("@" + parts[1]):"Twitter";
}
if(label == null)
label = a.href;
a.dataset.popup = label;
// Add the node at the start, so earlier links are at the right. This makes the
// more important links less likely to move around.
row.insertAdjacentElement("afterbegin", entry);
}
// Mute/unmute
if(user_id != null)
{
let entry = this.create_template({name: "mute-link", html: \`
\`});
let muted = muting.singleton.is_muted_user_id(user_id);
let a = entry.querySelector(".extra-link");
a.dataset.popup = \`\${muted? "Unmute":"Mute"} \${user_info?.name || "this user"}\`;
row.insertAdjacentElement("beforeend", entry);
a.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
if(muting.singleton.is_muted_user_id(user_id))
muting.singleton.unmute_user_id(user_id);
else
await actions.add_mute(user_id, null, {type: "user"});
});
}
// Tell the context menu which user is being viewed (if we're viewing a user-specific
// search).
main_context_menu.get.user_id = user_id;
}
// Refresh the slideshow button.
refresh_slideshow_button()
{
// For local images, set file=*. For Pixiv, set the media ID to *. Leave it alone
// if we're on the manga view and just add slideshow=1.
let args = helpers.args.location;
if(this.data_source.name == "vview")
args.hash.set("file", "*");
else if(this.data_source?.name != "manga")
this.data_source.set_current_media_id("*", args);
args.hash.set("slideshow", "1");
args.hash.set("view", "illust");
let node = this.container.querySelector("A.slideshow");
node.href = args.url;
}
// Use different icons for sites where you can give the artist money. This helps make
// the string of icons more meaningful (some artists have a lot of them).
find_link_image_type(url)
{
url = new URL(url);
let alt_icons = {
"shopping-cart": [
"dlsite.com",
"fantia.jp",
"skeb.jp",
"ko-fi.com",
"dmm.co.jp",
],
"twitter-icon": [
"twitter.com",
],
"fanbox-icon": [
"fanbox.cc",
],
"booth-icon": [
"booth.pm",
],
"twitch-icon": [
"twitch.tv",
],
};
// Special case for old Fanbox URLs that were under the Pixiv domain.
if((url.hostname == "pixiv.net" || url.hostname == "www.pixiv.net") && url.pathname.startsWith("/fanbox/"))
return "fanbox-icon";
for(let alt in alt_icons)
{
// "domain.com" matches domain.com and *.domain.com.
for(let domain of alt_icons[alt])
{
if(url.hostname == domain)
return alt;
if(url.hostname.endsWith("." + domain))
return alt;
}
}
return null;
};
async set_active(active, { data_source, old_media_id })
{
if(this._active == active && this.data_source == data_source)
return;
this._active = active;
await super.set_active(active);
if(active)
{
this.set_data_source(data_source);
this.initial_refresh_ui();
this.refresh_ui();
console.log("Showing search, came from media ID:", old_media_id);
// We might get data_source_updated callbacks during load_data_source_page.
// Make sure we ignore those, since we want the first refresh_images call
// to be the one we make below.
this.activating = true;
try {
// Make the first call to load_data_source_page, to load the initial page of images.
await this.load_data_source_page();
} finally {
this.activating = false;
}
// Show images. If we were displaying an image before we came here, forced_media_id
// will force it to be included in the displayed results.
this.finish_load_and_restore_scroll_pos(old_media_id);
this.container.querySelector(".search-results").focus();
}
else
{
this.stop_pulsing_thumbnail();
this.cancel_restore_scroll_pos();
main_context_menu.get.user_id = null;
}
}
// Wait for the initial page to finish loading, then restore the scroll position if possible.
async finish_load_and_restore_scroll_pos(old_media_id)
{
// Before we can set the scroll position, we need to wait for the initial page load to finish
// so we can create thumbnails to scroll to.
let restore_scroll_pos_id = this.restore_scroll_pos_id = new Object();
await this.data_source.load_page(this.data_source.initial_page, { cause: "initial scroll" });
// Stop if we were called again while we were waiting, or if we were cancelled.
if(restore_scroll_pos_id !== this.restore_scroll_pos_id || !this._active)
return;
// If the media ID isn't in the list, this might be a manga page beyond the first that
// isn't displayed, so try the first page instead.
if(old_media_id != null && this.get_thumbnail_for_media_id(old_media_id) == null)
old_media_id = helpers.get_media_id_first_page(old_media_id);
// Create the initial thumbnails. This will happen automatically, but we need to do it now so
// we can scroll to them.
this.refresh_images({ forced_media_id: old_media_id });
// If we have no saved scroll position or previous ID, scroll to the top.
let args = helpers.args.location;
if(args.state.scroll == null && old_media_id == null)
{
console.log("Scroll to top for new search");
this.scroll_container.scrollTop = 0;
return;
}
// If we have a previous media ID, try to scroll to it.
if(old_media_id != null)
{
// If we were displaying an image, pulse it to make it easier to find your place.
this.pulse_thumbnail(old_media_id);
// If we're navigating backwards or toggling, and we're switching from the image UI to thumbnails,
// try to scroll the search screen to the image that was displayed.
if(this.scroll_to_media_id(old_media_id))
{
console.log("Restored scroll position to:", old_media_id);
return;
}
console.log("Couldn't restore scroll position for:", old_media_id);
}
if(this.restore_scroll_position(args.state.scroll?.scroll_position))
console.log("Restored scroll position from history");
}
// Schedule storing the scroll position, resetting the timer if it's already running.
schedule_store_scroll_position()
{
if(this.scroll_position_timer != -1)
{
clearTimeout(this.scroll_position_timer);
this.scroll_position_timer = -1;
}
this.scroll_position_timer = setTimeout(() => {
this.store_scroll_position();
}, 100);
}
// Save the current scroll position, so it can be restored from history.
store_scroll_position()
{
let args = helpers.args.location;
args.state.scroll = {
scroll_position: this.save_scroll_position(),
nearby_media_ids: this.get_nearby_media_ids(),
};
helpers.set_page_url(args, false, "viewing-page", { send_popstate: false });
}
// Cancel any call to restore_scroll_pos that's waiting for data.
cancel_restore_scroll_pos()
{
this.restore_scroll_pos_id = null;
}
get active()
{
return this._active;
}
data_source_updated = () =>
{
this.refresh_ui();
// Don't load or refresh images if we're in the middle of set_active.
if(this.activating)
return;
this.refresh_images();
this.load_data_source_page();
}
// Return all media IDs currently loaded in the data source, and the page
// each one is on.
get_data_source_media_ids()
{
let media_ids = [];
let media_id_pages = {};
if(this.data_source == null)
return [media_ids, media_id_pages];
let id_list = this.data_source.id_list;
let min_page = id_list.get_lowest_loaded_page();
let max_page = id_list.get_highest_loaded_page();
for(let page = min_page; page <= max_page; ++page)
{
let media_ids_on_page = id_list.media_ids_by_page.get(page);
console.assert(media_ids_on_page != null);
// Create an image for each ID.
for(let media_id of media_ids_on_page)
{
// If this is a multi-page post and manga expansion is enabled, add a thumbnail for
// each page. We can only do this if the data source registers thumbnail info from
// its results, not if we have to look it up asynchronously, but almost all data sources
// do.
let media_ids_on_page = this.get_expanded_pages(media_id);
if(media_ids_on_page != null)
{
for(let page_media_id of media_ids_on_page)
{
media_ids.push(page_media_id);
media_id_pages[page_media_id] = page;
}
continue;
}
media_ids.push(media_id);
media_id_pages[media_id] = page;
}
}
return [media_ids, media_id_pages];
}
// If media_id is an expanded multi-page post, return the pages. Otherwise, return null.
get_expanded_pages(media_id)
{
if(!this.is_media_id_expanded(media_id))
return null;
let info = thumbnail_data.singleton().get_illust_data_sync(media_id);
if(info == null || info.pageCount <= 1)
return null;
let results = [];
let { type, id } = helpers.parse_media_id(media_id);
for(let manga_page = 0; manga_page < info.pageCount; ++manga_page)
{
let page_media_id = helpers.encode_media_id({type, id, page: manga_page});
results.push(page_media_id);
}
return results;
}
// Make a list of media IDs that we want loaded. This has a few inputs:
//
// - The thumbnails that are already loaded, if any.
// - A media ID that we want to have loaded. If we're coming back from viewing an image
// and it's in the search results, we always want that image loaded so we can scroll to
// it.
// - The thumbnails that are near the scroll position (nearby thumbs). These should always
// be loaded.
//
// Try to keep thumbnails that are already loaded in the list, since there's no performance
// benefit to unloading thumbs. Creating thumbs can be expensive if we're creating thousands of
// them, but once they're created, content-visibility keeps things fast.
//
// If forced_media_id is set and it's in the search results, always include it in the results,
// extending the list to include it. If forced_media_id is set and we also have thumbs already
// loaded, we'll extend the range to include both. If this would result in too many images
// being added at once, we'll remove previously loaded thumbs so forced_media_id takes priority.
//
// If we have no nearby thumbs and no ID to force load, it's an initial load, so we'll just
// start at the beginning.
//
// The result is always a contiguous subset of media IDs from the data source.
get_media_ids_to_display({all_media_ids, forced_media_id, columns})
{
if(all_media_ids.length == 0)
return [];
let [first_nearby_media_id, last_nearby_media_id] = this.get_nearby_media_ids();
let [first_loaded_media_id, last_loaded_media_id] = this.get_loaded_media_ids();
// If we're restoring a scroll position, use the nearby media IDs that we
// saved when we left, so we load the same range. Only do this for the initial
// refresh, when we don't already have thumbs nearby.
let args = helpers.args.location;
if(first_nearby_media_id == null && args.state.scroll?.nearby_media_ids != null)
{
first_nearby_media_id = args.state.scroll.nearby_media_ids[0];
last_nearby_media_id = args.state.scroll.nearby_media_ids[1];
}
// The indices of each related media_id. These can all be -1. Note that it's
// possible for nearby entries to not be in the data source, if the data source
// was just refreshed and entries were removed.
let first_nearby_media_id_idx = all_media_ids.indexOf(first_nearby_media_id);
let last_nearby_media_id_idx = all_media_ids.indexOf(last_nearby_media_id);
let first_loaded_media_id_idx = all_media_ids.indexOf(first_loaded_media_id);
let last_loaded_media_id_idx = all_media_ids.indexOf(last_loaded_media_id);
let forced_media_id_idx = all_media_ids.indexOf(forced_media_id);
// Figure out the range of all_media_ids that we want to have loaded.
let start_idx = 999999;
let end_idx = 0;
// If there are visible thumbs, extend the range to include them.
if(first_nearby_media_id_idx != -1)
start_idx = Math.min(start_idx, first_nearby_media_id_idx);
if(last_nearby_media_id_idx != -1)
end_idx = Math.max(end_idx, last_nearby_media_id_idx);
// If we have a media ID to display, extend the range to include it.
if(forced_media_id_idx != -1)
{
start_idx = Math.min(start_idx, forced_media_id_idx);
end_idx = Math.max(end_idx, forced_media_id_idx);
}
// If we have a range, extend it outwards in both directions to load images
// around it.
if(start_idx != 999999)
{
start_idx -= 10;
end_idx += 10;
}
// If there are thumbs already loaded, extend the range to include them. Do this
// after extending the range above.
if(first_loaded_media_id_idx != -1)
start_idx = Math.min(start_idx, first_loaded_media_id_idx);
if(last_loaded_media_id_idx != -1)
end_idx = Math.max(end_idx, last_loaded_media_id_idx);
// If we don't have anything, start at the beginning.
if(start_idx == 999999)
{
start_idx = 0;
end_idx = 0;
}
// Clamp the range.
start_idx = Math.max(start_idx, 0);
end_idx = Math.min(end_idx, all_media_ids.length-1);
end_idx = Math.max(start_idx, end_idx); // make sure start_idx <= end_idx
// If we're forcing an image to be included, and we also have images already
// loaded, we can end up with a huge range if the two are far apart. For example,
// if an image is loaded from a search, the user navigates for a long time in the
// image view and then returns to the search, we'll load the image he ended up on
// all the way to the images that were loaded before. Check the number of images
// we're adding, and if it's too big, ignore the previously loaded thumbs and just
// load IDs around forced_media_id.
if(forced_media_id_idx != -1)
{
// See how many thumbs this would cause us to load.
let loaded_thumb_ids = new Set();
for(let node of this.get_loaded_thumbs())
loaded_thumb_ids.add(node.dataset.id);
let loading_thumb_count = 0;
for(let thumb_id of all_media_ids.slice(start_idx, end_idx+1))
{
if(!loaded_thumb_ids.has(thumb_id))
loading_thumb_count++;
}
if(loading_thumb_count > 100)
{
console.log("Reducing loading_thumb_count from", loading_thumb_count);
start_idx = forced_media_id_idx - 10;
end_idx = forced_media_id_idx + 10;
start_idx = Math.max(start_idx, 0);
end_idx = Math.min(end_idx, all_media_ids.length-1);
}
}
// Snap the start of the range to the column count, so images always stay on the
// same column if we add entries to the beginning of the list. This only works if
// the data source provides all IDs at once, but if it doesn't then we won't
// auto-load earlier images anyway.
if(columns != null)
start_idx -= start_idx % columns;
let media_ids = all_media_ids.slice(start_idx, end_idx+1);
/*
console.log(
"Nearby range:", first_nearby_media_id_idx, "to", last_nearby_media_id_idx,
"Loaded range:", first_loaded_media_id_idx, "to", last_loaded_media_id_idx,
"Forced idx:", forced_media_id_idx,
"Returning:", start_idx, "to", end_idx);
*/
// Load thumbnail info for the results. We don't wait for this to finish.
this.load_thumbnail_data_for_media_ids(all_media_ids, start_idx, end_idx);
return media_ids;
}
load_thumbnail_data_for_media_ids(all_media_ids, start_idx, end_idx)
{
// Stop if the range is already loaded.
let media_ids = all_media_ids.slice(start_idx, end_idx+1);
if(thumbnail_data.singleton().are_all_media_ids_loaded_or_loading(media_ids))
return;
// Make a list of IDs that need to be loaded, removing ones that are already
// loaded.
let media_ids_to_load = [];
for(let media_id of media_ids)
{
if(!thumbnail_data.singleton().is_media_id_loaded_or_loading(media_id))
media_ids_to_load.push(media_id);
}
if(media_ids_to_load.length == 0)
return;
// Try not to request thumbnail info in tiny chunks. If we load them as they
// scroll on, we'll make dozens of requests for 4-5 thumbnails each and spam
// the API. Avoid this by extending the list outwards, so we load a bigger chunk
// in one request and then stop for a while.
//
// Don't do this for the local API. Making lots of tiny requests is harmless
// there since it's all local, and requesting file info causes the file to be
// scanned if it's not yet cached, so it's better to make fine-grained requests.
let min_to_load = this.data_source?.name == "vview"? 10: 30;
let load_start_idx = start_idx;
let load_end_idx = end_idx;
while(media_ids_to_load.length < min_to_load && (load_start_idx >= 0 || load_end_idx < all_media_ids.length))
{
let media_id = all_media_ids[load_start_idx];
if(media_id != null && !thumbnail_data.singleton().is_media_id_loaded_or_loading(media_id))
media_ids_to_load.push(media_id);
media_id = all_media_ids[load_end_idx];
if(media_id != null && !thumbnail_data.singleton().is_media_id_loaded_or_loading(media_id))
media_ids_to_load.push(media_id);
load_start_idx--;
load_end_idx++;
}
thumbnail_data.singleton().get_thumbnail_info(media_ids_to_load);
}
// Return the first and last media IDs that are nearby.
get_nearby_media_ids()
{
let nearby_thumbs = this.scroll_container.querySelectorAll(\`[data-id][data-nearby]:not([data-special])\`);
let first_nearby_media_id = nearby_thumbs[0]?.dataset?.id;
let last_nearby_media_id = nearby_thumbs[nearby_thumbs.length-1]?.dataset?.id;
return [first_nearby_media_id, last_nearby_media_id];
}
// Return the first and last media IDs that's currently loaded into thumbs.
get_loaded_media_ids()
{
let loaded_thumbs = this.scroll_container.querySelectorAll(\`[data-id]:not([data-special]\`);
let first_loaded_media_id = loaded_thumbs[0]?.dataset?.id;
let last_loaded_media_id = loaded_thumbs[loaded_thumbs.length-1]?.dataset?.id;
return [first_loaded_media_id, last_loaded_media_id];
}
refresh_images = ({forced_media_id=null}={}) =>
{
if(this.data_source == null)
return;
let manga_view = this.data_source?.name == "manga";
// Update the thumbnail size style. This also tells us the number of columns being
// displayed.
let ul = this.container.querySelector(".thumbnails");
let thumbnail_size = settings.get(manga_view? "manga-thumbnail-size":"thumbnail-size", 4);
thumbnail_size = thumbnail_size_slider_widget.thumbnail_size_for_value(thumbnail_size);
let {columns, padding, max_width, max_height, container_width} = helpers.make_thumbnail_sizing_style(ul, {
wide: true,
size: thumbnail_size,
ratio: this.data_source.get_thumbnail_aspect_ratio(),
// Limit the number of columns on most views, so we don't load too much data at once.
// Allow more columns on the manga view, since that never loads more than one image.
// Allow unlimited columns for local images.
max_columns: manga_view? 15:
this.data_source?.name == "vview"? 100:5,
// Set a minimum padding to make sure there's room for the popup text to fit between images.
min_padding: 15,
});
this.container.style.setProperty('--thumb-width', \`\${max_width}px\`);
this.container.style.setProperty('--thumb-height', \`\${max_height}px\`);
this.container.style.setProperty('--thumb-padding', \`\${padding}px\`);
this.container.style.setProperty('--container-width', \`\${container_width}px\`);
// Save the scroll position relative to the first thumbnail. Do this before making
// any changes.
let saved_scroll = this.save_scroll_position();
// Remove special:previous-page if it's in the list. It'll confuse the insert logic.
// We'll add it at the end if it should be there.
let special = this.container.querySelector(\`.thumbnails > [data-special]\`);
if(special)
special.remove();
// Get all media IDs from the data source.
let [all_media_ids, media_id_pages] = this.get_data_source_media_ids();
// Sanity check: there should never be any duplicate media IDs from the data source.
// Refuse to continue if there are duplicates, since it'll break our logic badly and
// can cause infinite loops. This is always a bug.
if(all_media_ids.length != (new Set(all_media_ids)).size)
throw Error("Duplicate media IDs");
// Remove any thumbs that aren't present in all_media_ids, so we only need to
// deal with adding thumbs below. For example, this simplifies things when
// a manga post is collapsed.
{
let media_id_set = new Set(all_media_ids);
for(let thumb of this.scroll_container.querySelectorAll(\`[data-id]\`))
{
let thumb_media_id = thumb.dataset.id;
if(!media_id_set.has(thumb_media_id))
thumb.remove();
}
}
// Get the thumbnail media IDs to display.
let media_ids = this.get_media_ids_to_display({
all_media_ids,
columns,
forced_media_id,
});
// Add thumbs.
//
// Most of the time we're just adding thumbs to the list. Avoid removing or recreating
// thumbs that aren't actually changing, which reduces flicker.
//
// Do this by looking for a range of thumbnails that matches a range in media_ids.
// If we're going to display [0,1,2,3,4,5,6,7,8,9], and the current thumbs are [4,5,6],
// then 4,5,6 matches and can be reused. We'll add [0,1,2,3] to the beginning and [7,8,9]
// to the end.
//
// Most of the time we're just appending. The main time that we add to the beginning is
// the "load previous results" button.
// Make a dictionary of all illust IDs and pages, so we can look them up quickly.
let media_id_index = {};
for(let i = 0; i < media_ids.length; ++i)
{
let media_id = media_ids[i];
media_id_index[media_id] = i;
}
let get_node_idx = function(node)
{
if(node == null)
return null;
let media_id = node.dataset.id;
return media_id_index[media_id];
}
// Find the first match (4 in the above example).
let first_matching_node = ul.firstElementChild;
while(first_matching_node && get_node_idx(first_matching_node) == null)
first_matching_node = first_matching_node.nextElementSibling;
// If we have a first_matching_node, walk forward to find the last matching node (6 in
// the above example).
let last_matching_node = first_matching_node;
if(last_matching_node != null)
{
// Make sure the range is contiguous. first_matching_node and all nodes through last_matching_node
// should match a range exactly. If there are any missing entries, stop.
let next_expected_idx = get_node_idx(last_matching_node) + 1;
while(last_matching_node && get_node_idx(last_matching_node.nextElementSibling) == next_expected_idx)
{
last_matching_node = last_matching_node.nextElementSibling;
next_expected_idx++;
}
}
// When we remove thumbs, we'll cache them here, so if we end up reusing it we don't have
// to recreate it.
let removed_nodes = {};
function remove_node(node)
{
node.remove();
removed_nodes[node.dataset.id] = node;
}
// If we have a range, delete all items outside of it. Otherwise, just delete everything.
while(first_matching_node && first_matching_node.previousElementSibling)
remove_node(first_matching_node.previousElementSibling);
while(last_matching_node && last_matching_node.nextElementSibling)
remove_node(last_matching_node.nextElementSibling);
if(!first_matching_node && !last_matching_node)
{
while(ul.firstElementChild != null)
remove_node(ul.firstElementChild);
}
// If we have a matching range, add any new elements before it.
if(first_matching_node)
{
let first_idx = get_node_idx(first_matching_node);
for(let idx = first_idx - 1; idx >= 0; --idx)
{
let media_id = media_ids[idx];
let search_page = media_id_pages[media_id];
let node = this.create_thumb(media_id, search_page, { cached_nodes: removed_nodes });
first_matching_node.insertAdjacentElement("beforebegin", node);
first_matching_node = node;
}
}
// Add any new elements after the range. If we don't have a range, just add everything.
let last_idx = -1;
if(last_matching_node)
last_idx = get_node_idx(last_matching_node);
for(let idx = last_idx + 1; idx < media_ids.length; ++idx)
{
let media_id = media_ids[idx];
let search_page = media_id_pages[media_id];
let node = this.create_thumb(media_id, search_page, { cached_nodes: removed_nodes });
ul.appendChild(node);
}
// If this data source supports a start page and we started after page 1, add the "load more"
// button at the beginning.
if(this.data_source && this.data_source.initial_page > 1)
{
// Reuse the node if we removed it earlier.
if(special == null)
special = this.create_thumb("special:previous-page", null, { cached_nodes: removed_nodes });
ul.insertAdjacentElement("afterbegin", special);
}
this.restore_scroll_position(saved_scroll);
}
// Start loading data pages that we need to display visible thumbs, and start
// loading thumbnail data for nearby thumbs.
async load_data_source_page()
{
// We load pages when the last thumbs on the previous page are loaded, but the first
// time through there's no previous page to reach the end of. Always make sure the
// first page is loaded (usually page 1).
let load_page = null;
if(this.data_source && !this.data_source.is_page_loaded_or_loading(this.data_source.initial_page))
load_page = this.data_source.initial_page;
else
{
// If the last thumb in the list is visible, we need the next page to continue.
// Note that since get_nearby_thumbnails returns thumbs before they actually scroll
// into view, this will happen before the last thumb is actually visible to the user.
let elements = this.get_nearby_thumbnails();
if(elements.length > 0 && elements[elements.length-1].nextElementSibling == null)
{
let last_element = elements[elements.length-1];
load_page = parseInt(last_element.dataset.searchPage)+1;
}
}
// Hide "no results" if it's shown while we load data.
this.container.querySelector(".no-results").hidden = true;
if(load_page != null)
{
var result = await this.data_source.load_page(load_page, { cause: "thumbnails" });
// If this page didn't load, it probably means we've reached the end, so stop trying
// to load more pages.
if(!result)
this.disable_loading_more_pages = true;
}
// If we have no IDs and nothing is loading, the data source is empty (no results).
if(this.data_source && this.data_source.id_list.get_first_id() == null && !this.data_source.any_page_loading)
this.container.querySelector(".no-results").hidden = false;
this.set_visible_thumbs();
}
// Handle clicks on the "load previous results" button.
//
// If we let the regular click handling in main_controller.set_current_data_source do this,
// it'll notice that the requested page isn't loaded and create a new data source. We know
// we can view the previous page, so special case this so we don't lose the pages that are
// already loaded.
//
// This can also trigger for the "return to start" button if we happen to be on page 2.
thumbnail_onclick = async(e) =>
{
let page_count_box = e.target.closest(".expand-button");
if(page_count_box)
{
e.preventDefault();
e.stopPropagation();
let id_node = page_count_box.closest("[data-id]");
let media_id = id_node.dataset.id;
this.set_media_id_expanded(media_id, !this.is_media_id_expanded(media_id));
return;
}
// This only matters if the data source supports start pages.
if(!this.data_source.supports_start_page)
return;
let a = e.target.closest("A");
if(a == null)
return;
// Don't do this for the "return to start" button. That page does link to the previous
// page, but that button should always refresh so we scroll to the top, and not just add
// the previous page above where we are like this does.
if(a.classList.contains("load-first-page-link"))
return;
if(a.classList.contains("load-previous-page-link"))
{
let page = this.data_source.id_list.get_lowest_loaded_page() - 1;
this.load_page(page);
e.preventDefault();
e.stopImmediatePropagation();
}
}
// See if we can load page in-place. Return true if we were able to, and the click that
// requested it should be cancelled, or false if we can't and it should be handled as a
// regular navigation.
async load_page(page)
{
// We can only add pages that are immediately before or after the pages we currently have.
let min_page = this.data_source.id_list.get_lowest_loaded_page();
let max_page = this.data_source.id_list.get_highest_loaded_page();
if(page < min_page-1)
return false;
if(page > max_page+1)
return false;
console.log("Loading page:", page);
await this.data_source.load_page(page, { cause: "previous page" });
return true;
}
// Save the current scroll position relative to the first visible thumbnail.
// The result can be used with restore_scroll_position.
save_scroll_position()
{
let first_visible_thumb_node = this.get_first_visible_thumb();
if(first_visible_thumb_node == null)
return null;
return {
saved_scroll: helpers.save_scroll_position(this.scroll_container, first_visible_thumb_node),
media_id: first_visible_thumb_node.dataset.id,
}
}
// Restore the scroll position from a position saved by save_scroll_position.
restore_scroll_position(scroll)
{
if(scroll == null)
return false;
// Find the thumbnail for the media_id the scroll position was saved at.
let restore_scroll_position_node = this.get_thumbnail_for_media_id(scroll.media_id);
if(restore_scroll_position_node == null)
return false;
helpers.restore_scroll_position(this.scroll_container, restore_scroll_position_node, scroll.saved_scroll);
return true;
}
// Set whether the given thumb is expanded.
//
// We can store a thumb being explicitly expanded or explicitly collapsed, overriding the
// current default.
set_media_id_expanded(media_id, new_value)
{
media_id = helpers.get_media_id_first_page(media_id);
this.expanded_media_ids.set(media_id, new_value);
this.save_expanded_media_ids();
// This will cause thumbnails to be added or removed, so refresh.
this.refresh_images();
// Refresh whether we're showing the expansion border. refresh_images sets this when it's
// created, but it doesn't handle refreshing it.
let thumb = this.get_thumbnail_for_media_id(media_id);
this.refresh_expanded_thumb(thumb);
}
// Set whether thumbs are expanded or collapsed by default.
toggle_expanding_media_ids_by_default()
{
// If the new setting is the same as the expand_manga_thumbnails setting, just
// remove expand-thumbs. Otherwise, set it to the overridden setting.
let args = helpers.args.location;
let new_value = !this.media_ids_expanded_by_default;
if(new_value == settings.get("expand_manga_thumbnails"))
args.hash.delete("expand-thumbs");
else
args.hash.set("expand-thumbs", new_value? "1":"0");
// Clear manually expanded/unexpanded thumbs, and navigate to the new setting.
delete args.state.expanded_media_ids;
helpers.set_page_url(args, true, "viewing-page");
}
load_expanded_media_ids()
{
// Load expanded_media_ids.
let args = helpers.args.location;
let media_ids = args.state.expanded_media_ids ?? {};
this.expanded_media_ids = new Map(Object.entries(media_ids));
// Load media_ids_expanded_by_default.
let expand_thumbs = args.hash.get("expand-thumbs");
if(expand_thumbs == null)
this.media_ids_expanded_by_default = settings.get("expand_manga_thumbnails");
else
this.media_ids_expanded_by_default = expand_thumbs == "1";
}
// Store this.expanded_media_ids to history.
save_expanded_media_ids()
{
let args = helpers.args.location;
args.state.expanded_media_ids = Object.fromEntries(this.expanded_media_ids);
helpers.set_page_url(args, false, "viewing-page", { send_popstate: false });
}
is_media_id_expanded(media_id)
{
// Never expand manga posts on data sources that include manga pages themselves.
// This can result in duplicate media IDs.
if(this.data_source?.includes_manga_pages)
return false;
media_id = helpers.get_media_id_first_page(media_id);
// Only illust IDs can be expanded.
let { type } = helpers.parse_media_id(media_id);
if(type != "illust")
return false;
// Check if the user has manually expanded or collapsed the image.
if(this.expanded_media_ids.has(media_id))
return this.expanded_media_ids.get(media_id);
// The media ID hasn't been manually expanded or unexpanded. If we're not expanding
// by default, it's unexpanded.
if(!this.media_ids_expanded_by_default)
return false;
// If the image is muted, never expand it by default, even if we're set to expand by default.
// We'll just show a wall of muted thumbs.
let info = thumbnail_data.singleton().get_one_thumbnail_info(media_id);
if(info != null)
{
let muted_tag = muting.singleton.any_tag_muted(info.tagList);
let muted_user = muting.singleton.is_muted_user_id(info.userId);
if(muted_tag || muted_user)
return false;
}
// Otherwise, it's expanded by default if it has more than one page.
if(info == null || info.pageCount == 1)
return false;
return true;
}
// Refresh the expanded-thumb class on thumbnails after expanding or unexpanding a manga post.
refresh_expanded_thumb(thumb)
{
if(thumb == null)
return;
// Don't set expanded-thumb on the manga view, since it's always expanded.
let media_id = thumb.dataset.id;
let show_expanded = !this.data_source?.includes_manga_pages && this.is_media_id_expanded(media_id);
helpers.set_class(thumb, "expanded-thumb", show_expanded);
}
// Refresh all expanded thumbs. This is only needed if the default changes.
refresh_expanded_thumb_all()
{
for(let thumb of this.get_loaded_thumbs())
this.refresh_expanded_thumb(thumb);
}
// Refresh the highlight for the "expand all posts" button.
refresh_expand_manga_posts_button()
{
let enabled = this.media_ids_expanded_by_default;
let button = this.container.querySelector(".expand-manga-posts");
button.dataset.popup = enabled? "Collapse manga posts":"Expand manga posts";
button.querySelector(".material-icons").innerText = enabled? "close_fullscreen":"open_in_full";
// Hide the button if the data source can never return manga posts to be expanded, or
// if it's the manga page itself which always expands.
button.hidden =
!this.data_source?.can_return_manga ||
this.data_source?.includes_manga_pages;
}
update_from_settings = () =>
{
this.load_expanded_media_ids(); // in case expand_manga_thumbnails has changed
this.set_visible_thumbs();
this.refresh_images();
this.refresh_expanded_thumb_all();
document.body.dataset.theme = "dark"; //settings.get("theme");
helpers.set_class(document.body, "disable-thumbnail-panning", settings.get("disable_thumbnail_panning"));
helpers.set_class(document.body, "disable-thumbnail-zooming", settings.get("disable_thumbnail_zooming"));
helpers.set_class(document.body, "ui-on-hover", settings.get("ui-on-hover"));
helpers.set_class(this.container.querySelector(".recent-history-link"), "disabled", !ppixiv.recently_seen_illusts.get().enabled);
this.refresh_expand_manga_posts_button();
// Flush the top UI transition, so it doesn't animate weirdly when toggling ui-on-hover.
for(let box of document.querySelectorAll(".top-ui-box"))
{
box.classList.add("disable-transition");
box.offsetHeight;
box.classList.remove("disable-transition");
}
}
// Set the URL for all loaded thumbnails that are onscreen.
//
// This won't trigger loading any data (other than the thumbnails themselves).
set_visible_thumbs({force=false}={})
{
// Make a list of IDs that we're assigning.
var elements = this.get_nearby_thumbnails();
for(var element of elements)
{
let media_id = element.dataset.id;
if(media_id == null)
continue;
let [illust_id, illust_page] = helpers.media_id_to_illust_id_and_page(media_id);
let { id: thumb_id, type: thumb_type } = helpers.parse_media_id(media_id);
// For illustrations, get thumbnail info. If we don't have it yet, skip the image (leave it pending)
// and we'll come back once we have it.
if(thumb_type == "illust" || thumb_type == "file" || thumb_type == "folder")
{
// Get thumbnail info.
var info = thumbnail_data.singleton().get_one_thumbnail_info(media_id);
if(info == null)
continue;
}
// Leave it alone if it's already been loaded.
if(!force && !("pending" in element.dataset))
continue;
// Why is this not working in FF? It works in the console, but not here. Sandboxing
// issue?
// delete element.dataset.pending;
element.removeAttribute("data-pending");
if(thumb_type == "user" || thumb_type == "bookmarks")
{
// This is a user thumbnail rather than an illustration thumbnail. It just shows a small subset
// of info.
let user_id = thumb_id;
var link = element.querySelector("a.thumbnail-link");
if(thumb_type == "user")
link.href = \`/users/\${user_id}/artworks#ppixiv\`;
else
link.href = \`/users/\${user_id}/bookmarks/artworks#ppixiv\`;
link.dataset.userId = user_id;
let quick_user_data = thumbnail_data.singleton().get_quick_user_data(user_id);
if(quick_user_data == null)
{
// We should always have this data for users if the data source asked us to display this user.
throw "Missing quick user data for user ID " + user_id;
}
var thumb = element.querySelector(".thumb");
thumb.src = quick_user_data.profileImageUrl;
var label = element.querySelector(".thumbnail-label");
label.hidden = false;
label.querySelector(".label").innerText = quick_user_data.userName;
continue;
}
if(thumb_type != "illust" && thumb_type != "file" && thumb_type != "folder")
throw "Unexpected thumb type: " + thumb_type;
// Set this thumb.
let { page } = helpers.parse_media_id(media_id);
let url = info.previewUrls[page];
var thumb = element.querySelector(".thumb");
// Check if this illustration is muted (blocked).
var muted_tag = muting.singleton.any_tag_muted(info.tagList);
var muted_user = muting.singleton.is_muted_user_id(info.userId);
if(muted_tag || muted_user)
{
// The image will be obscured, but we still shouldn't load the image the user blocked (which
// is something Pixiv does wrong). Load the user profile image instead.
thumb.src = thumbnail_data.singleton().get_profile_picture_url(info.userId);
element.classList.add("muted");
let muted_label = element.querySelector(".muted-label");
// Quick hack to look up translations, since we're not async:
(async() => {
if(muted_tag)
muted_tag = await tag_translations.get().get_translation(muted_tag);
muted_label.textContent = muted_tag? muted_tag:info.userName;
})();
// We can use this if we want a "show anyway' UI.
thumb.dataset.mutedUrl = url;
}
else
{
thumb.src = url;
element.classList.remove("muted");
// Try to set up the aspect ratio.
this.thumb_image_load_finished(element, { cause: "setup" });
}
// Set the link. Setting dataset.mediaId will allow this to be handled with in-page
// navigation, and the href will allow middle click, etc. to work normally.
var link = element.querySelector("a.thumbnail-link");
if(thumb_type == "folder")
{
// This is a local directory. We only expect to see this while on the local
// data source. The folder link retains any search parameters in the URL.
let args = helpers.args.location;
local_api.get_args_for_id(media_id, args);
link.href = args.url;
element.querySelector(".manga-info-box").hidden = false;
}
else
{
link.href = helpers.get_url_for_id(media_id);
}
link.dataset.mediaId = media_id;
link.dataset.userId = info.userId;
element.querySelector(".ugoira-icon").hidden = info.illustType != 2 && info.illustType != "video";
// Show the page count if this is a multi-page post (unless we're on the
// manga view itself).
if(info.pageCount > 1 && page == 0 && this.data_source?.name != "manga")
{
let pageCountBox = element.querySelector(".manga-info-box");
pageCountBox.hidden = false;
element.querySelector(".manga-info-box .page-count").textContent = info.pageCount;
element.querySelector(".manga-info-box .page-count").hidden = false;
let page_count_box2 = element.querySelector(".show-manga-pages-button");
page_count_box2.hidden = false;
page_count_box2.href = \`/artworks/\${illust_id}#ppixiv?manga=1\`;
}
helpers.set_class(element, "dot", helpers.tags_contain_dot(info));
// Set expanded-thumb if this is an expanded manga post. This is also updated in
// set_media_id_expanded. Set the border to a random-ish value to try to make it
// easier to see the boundaries between manga posts. It's hard to guarantee that it
// won't be the same color as a neighboring post, but that's rare. Using the illust
// ID means the color will always be the same. The saturation is a bit low so these
// colors aren't blinding.
this.refresh_expanded_thumb(element);
helpers.set_class(link, "first-page", illust_page == 0);
helpers.set_class(link, "last-page", illust_page == info.pageCount-1);
link.style.borderBottomColor = \`hsl(\${illust_id}deg 50% 50%)\`;
this.refresh_bookmark_icon(element);
// Set the label. This is only actually shown in following views.
var label = element.querySelector(".thumbnail-label");
if(thumb_type == "folder")
{
// The ID is based on the filename. Use it to show the directory name in the thumbnail.
let parts = media_id.split("/");
let basename = parts[parts.length-1];
let label = element.querySelector(".thumbnail-label");
label.hidden = false;
label.querySelector(".label").innerText = basename;
} else {
label.hidden = true;
}
}
if(this.data_source != null)
{
// Set the link for the first page and previous page buttons. Most of the time this is handled
// by our in-page click handler.
let page = this.data_source.get_start_page(helpers.args.location);
let previous_page_link = this.container.querySelector("a.load-previous-page-link");
if(previous_page_link)
{
let args = helpers.args.location;
this.data_source.set_start_page(args, page-1);
previous_page_link.href = args.url;
}
let first_page_link = this.container.querySelector("a.load-first-page-link");
if(first_page_link)
{
let args = helpers.args.location;
this.data_source.set_start_page(args, 1);
first_page_link.href = args.url;
}
}
}
// Set things up based on the image dimensions. We can do this immediately if we know the
// thumbnail dimensions already, otherwise we'll do it based on the thumbnail once it loads.
thumb_image_load_finished(element, { cause })
{
if(element.dataset.thumbLoaded)
return;
let media_id = element.dataset.id;
let [illust_id, illust_page] = helpers.media_id_to_illust_id_and_page(media_id);
let thumb = element.querySelector(".thumb");
// Try to use thumbnail info first. Preferring this makes things more consistent,
// since naturalWidth may or may not be loaded depending on browser cache.
let width, height;
if(illust_page == 0)
{
let info = thumbnail_data.singleton().get_one_thumbnail_info(media_id);
if(info != null)
{
width = info.width;
height = info.height;
}
}
// If that wasn't available, try to use the dimensions from the image. This is the size
// of the thumb rather than the image, but all we care about is the aspect ratio.
if(width == null && thumb.naturalWidth != 0)
{
width = thumb.naturalWidth;
height = thumb.naturalHeight;
}
if(width == null)
return;
element.dataset.thumbLoaded = "1";
// Set up the thumbnail panning direction, which is based on the image aspect ratio and the
// displayed thumbnail aspect ratio. Ths thumbnail aspect ratio is usually 1 for square thumbs,
// but it can be different on the manga page.
let thumb_aspect_ratio = thumb.offsetWidth / thumb.offsetHeight;
// console.log(\`Thumbnail \${media_id} loaded at \${cause}: \${width} \${height} \${thumb.src}\`);
helpers.set_thumbnail_panning_direction(element, width, height, thumb_aspect_ratio);
}
// Refresh the thumbnail for media_id.
//
// This is used to refresh the bookmark icon when changing a bookmark.
refresh_thumbnail = (media_id) =>
{
// If this is a manga post, refresh all thumbs for this media ID, since bookmarking
// a manga post is shown on all pages if it's expanded.
let thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(media_id);
if(thumbnail_info == null)
return;
for(let page = 0; page < thumbnail_info.pageCount; ++page)
{
media_id = helpers.get_media_id_for_page(media_id, page);
let thumbnail_element = this.get_thumbnail_for_media_id(media_id);
if(thumbnail_element != null)
this.refresh_bookmark_icon(thumbnail_element);
}
}
// Set the bookmarked heart for thumbnail_element. This can change if the user bookmarks
// or un-bookmarks an image.
refresh_bookmark_icon(thumbnail_element)
{
if(this.data_source && this.data_source.name == "manga")
return;
var media_id = thumbnail_element.dataset.id;
if(media_id == null)
return;
// Get thumbnail info.
var thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(media_id);
if(thumbnail_info == null)
return;
var show_bookmark_heart = thumbnail_info.bookmarkData != null;
if(this.data_source != null && !this.data_source.show_bookmark_icons)
show_bookmark_heart = false;
thumbnail_element.querySelector(".heart.public").hidden = !show_bookmark_heart || thumbnail_info.bookmarkData.private;
thumbnail_element.querySelector(".heart.private").hidden = !show_bookmark_heart || !thumbnail_info.bookmarkData.private;
}
// Force all thumbnails to refresh after the mute list changes, to refresh mutes.
refresh_after_mute_change = () =>
{
// Force the update to refresh thumbs that have already been created.
this.set_visible_thumbs({force: true});
// Refresh the user ID-dependant UI so we refresh the mute/unmute button.
this.refresh_ui_for_user_id();
}
// Return a list of thumbnails that are either visible, or close to being visible
// (so we load thumbs before they actually come on screen).
get_nearby_thumbnails()
{
// If the container has a zero height, that means we're hidden and we don't want to load
// thumbnail data at all.
if(this.container.offsetHeight == 0)
return [];
// Don't include data-special, which are non-thumb entries like "load previous results".
return this.container.querySelectorAll(\`.thumbnails > [data-id][data-nearby]:not([data-special])\`);
}
get_loaded_thumbs()
{
return this.container.querySelectorAll(\`.thumbnails > [data-id]:not([data-special])\`);
}
// Create a thumb placeholder. This doesn't load the image yet.
//
// media_id is the illustration this will be if it's displayed, or null if this
// is a placeholder for pages we haven't loaded. page is the page this illustration
// is on (whether it's a placeholder or not).
//
// cached_nodes is a dictionary of previously-created nodes that we can reuse.
create_thumb(media_id, search_page, { cached_nodes })
{
if(cached_nodes[media_id] != null)
{
let result = cached_nodes[media_id];
delete cached_nodes[media_id];
return result;
}
let entry = null;
if(media_id == "special:previous-page")
{
entry = this.create_template({ name: "load-previous-results", html: \`
\`});
entry.dataset.tag = tag_search;
let translated_tag = translated_tags[tag_search];
if(translated_tag)
entry.dataset.translated_tag = translated_tag;
let tag_container = entry.querySelector(".search");
for(let tag of helpers.split_search_tags(tag_search))
{
if(tag == "")
continue;
// Force "or" lowercase.
if(tag.toLowerCase() == "or")
tag = "or";
let span = document.createElement("span");
span.dataset.tag = tag;
span.classList.add("word");
if(tag == "or")
span.classList.add("or");
else
span.classList.add("tag");
// Split off - prefixes to look up the translation, then add it back.
let prefix_and_tag = helpers.split_tag_prefixes(tag);
let translated_tag = translated_tags[prefix_and_tag[1]];
if(translated_tag)
translated_tag = prefix_and_tag[0] + translated_tag;
span.innerText = translated_tag || tag;
if(translated_tag)
span.dataset.translated_tag = translated_tag;
tag_container.appendChild(span);
}
var url = page_manager.singleton().get_url_for_tag_search(tag_search, ppixiv.location);
entry.href = url;
return entry;
}
set_selection(idx)
{
// Temporarily set this.navigating to true. This lets run_autocomplete know that
// it shouldn't run an autocomplete request for this value change.
this.navigating = true;
try {
// If there's an autocomplete request in the air, cancel it.
if(this.abort_autocomplete != null)
this.abort_autocomplete.abort();
// Clear any old selection.
var all_entries = this.container.querySelectorAll(".input-dropdown-list .entry");
if(this.selected_idx != null)
all_entries[this.selected_idx].classList.remove("selected");
// Set the new selection.
this.selected_idx = idx;
if(this.selected_idx != null)
{
var new_entry = all_entries[this.selected_idx];
new_entry.classList.add("selected");
this.input_element.value = new_entry.dataset.tag;
}
} finally {
this.navigating = false;
}
}
// Select the next or previous entry in the dropdown.
move(down)
{
var all_entries = this.container.querySelectorAll(".input-dropdown-list .entry");
// Stop if there's nothing in the list.
var total_entries = all_entries.length;
if(total_entries == 0)
return;
var idx = this.selected_idx;
if(idx == null)
idx = down? 0:(total_entries-1);
else
idx += down? +1:-1;
idx %= total_entries;
this.set_selection(idx);
}
// Populate the tag dropdown.
//
// This is async, since IndexedDB is async. (It shouldn't be. It's an overcorrection.
// Network APIs should be async, but local I/O should not be forced async.) If another
// call to populate_dropdown() is made before this completes or cancel_populate_dropdown
// cancels it, return false. If it completes, return true.
populate_dropdown = async() =>
{
// If another populate_dropdown is already running, cancel it and restart.
this.cancel_populate_dropdown();
// Set populate_dropdown_abort to an AbortController for this call.
let abort_controller = this.populate_dropdown_abort = new AbortController();
let abort_signal = abort_controller.signal;
var tag_searches = settings.get("recent-tag-searches") || [];
// Separate tags in each search, so we can look up translations.
//
var all_tags = {};
for(let tag_search of tag_searches)
{
for(let tag of helpers.split_search_tags(tag_search))
{
tag = helpers.split_tag_prefixes(tag)[1];
all_tags[tag] = true;
}
}
all_tags = Object.keys(all_tags);
let translated_tags = await tag_translations.get().get_translations(all_tags, "en");
// Check if we were aborted while we were loading tags.
if(abort_signal && abort_signal.aborted)
{
console.log("populate_dropdown_inner aborted");
return false;
}
var list = this.container.querySelector(".input-dropdown-list");
helpers.remove_elements(list);
this.selected_idx = null;
var autocompleted_tags = this.current_autocomplete_results;
for(var tag of autocompleted_tags)
{
var entry = this.create_entry(tag.tag_name, translated_tags);
entry.classList.add("autocomplete");
list.appendChild(entry);
}
for(var tag of tag_searches)
{
var entry = this.create_entry(tag, translated_tags);
entry.classList.add("history");
list.appendChild(entry);
}
return true;
}
cancel_populate_dropdown()
{
if(this.populate_dropdown_abort == null)
return;
this.populate_dropdown_abort.abort();
}
}
ppixiv.tag_search_edit_widget = class extends ppixiv.widget
{
constructor({input_element, ...options})
{
super({...options, template: \`
\`});
this.input_element = input_element.querySelector("input");
// Refresh the dropdown when the tag search history changes.
window.addEventListener("recent-tag-searches-changed", this.populate_dropdown);
this.container.addEventListener("click", this.dropdown_onclick);
// Refresh tags if the user edits the search directly.
this.input_element.addEventListener("input", (e) => { this.refresh_highlighted_tags(); });
// input-dropdown is resizable. Save the size when the user drags it.
this.input_dropdown = this.container.querySelector(".input-dropdown");
let observer = new MutationObserver((mutations) => {
// resize sets the width. Use this instead of offsetWidth, since offsetWidth sometimes reads
// as 0 here.
settings.set("search-edit-dropdown-width", this.input_dropdown.style.width);
});
observer.observe(this.input_dropdown, { attributes: true });
// Restore input-dropdown's width. Force a minimum width, in case this setting is saved incorrectly.
this.input_dropdown.style.width = settings.get("search-edit-dropdown-width", "400px");
this.shown = false;
this.container.hidden = true;
}
dropdown_onclick = (e) =>
{
e.preventDefault();
e.stopImmediatePropagation();
// Clicking tags toggles the tag in the search box.
let tag = e.target.closest(".tag");
if(tag == null)
return;
this.toggle_tag(tag.dataset.tag);
// Control-clicking the tag probably caused its enclosing search link to be focused, which will
// cause it to activate when enter is pressed. Switch focus to the input box, so pressing enter
// will submit the search.
this.input_element.focus();
}
async show()
{
if(this.shown)
return;
this.shown = true;
// Fill in the dropdown before displaying it. If hide() is called before this
// finishes this will return false, so stop.
if(!await this.populate_dropdown())
return;
this.container.hidden = false;
}
hide()
{
if(!this.shown)
return;
this.shown = false;
// If populate_dropdown is still running, cancel it.
this.cancel_populate_dropdown();
this.container.hidden = true;
// Make sure the input isn't focused.
this.input_element.blur();
}
// tag_search is a search, like "tag -tag2". translated_tags is a dictionary of known translations.
create_entry(tag_search, translated_tags)
{
let entry = this.create_template({name: "dropdown-entry", html: \`
\`});
entry.dataset.tag = tag_search;
let translated_tag = translated_tags[tag_search];
if(translated_tag)
entry.dataset.translated_tag = translated_tag;
let tag_container = entry.querySelector(".search");
for(let tag of helpers.split_search_tags(tag_search))
{
if(tag == "")
continue;
let span = document.createElement("span");
span.dataset.tag = tag;
span.classList.add("word");
if(tag != "or")
span.classList.add("tag");
// Split off - prefixes to look up the translation, then add it back.
let prefix_and_tag = helpers.split_tag_prefixes(tag);
let translated_tag = translated_tags[prefix_and_tag[1]];
if(translated_tag)
translated_tag = prefix_and_tag[0] + translated_tag;
span.innerText = translated_tag || tag;
if(translated_tag)
span.dataset.translated_tag = translated_tag;
tag_container.appendChild(span);
}
var url = page_manager.singleton().get_url_for_tag_search(tag_search, ppixiv.location);
entry.querySelector("A.search").href = url;
return entry;
}
// Populate the tag dropdown.
//
// This is async, since IndexedDB is async. (It shouldn't be. It's an overcorrection.
// Network APIs should be async, but local I/O should not be forced async.) If another
// call to populate_dropdown() is made before this completes or cancel_populate_dropdown
// cancels it, return false. If it completes, return true.
populate_dropdown = async() =>
{
// If another populate_dropdown is already running, cancel it and restart.
this.cancel_populate_dropdown();
// Set populate_dropdown_abort to an AbortController for this call.
let abort_controller = this.populate_dropdown_abort = new AbortController();
let abort_signal = abort_controller.signal;
var tag_searches = settings.get("recent-tag-searches") || [];
// Individually show all tags in search history.
var all_tags = {};
for(let tag_search of tag_searches)
{
for(let tag of helpers.split_search_tags(tag_search))
{
tag = helpers.split_tag_prefixes(tag)[1];
// Ignore "or".
if(tag == "" || tag == "or")
continue;
all_tags[tag] = true;
}
}
all_tags = Object.keys(all_tags);
let translated_tags = await tag_translations.get().get_translations(all_tags, "en");
// Sort tags by their translation.
all_tags.sort((lhs, rhs) => {
if(translated_tags[lhs]) lhs = translated_tags[lhs];
if(translated_tags[rhs]) rhs = translated_tags[rhs];
return lhs.localeCompare(rhs);
});
// Check if we were aborted while we were loading tags.
if(abort_signal && abort_signal.aborted)
{
console.log("populate_dropdown_inner aborted");
return false;
}
var list = this.container.querySelector(".input-dropdown-list");
helpers.remove_elements(list);
for(var tag of all_tags)
{
var entry = this.create_entry(tag, translated_tags);
list.appendChild(entry);
}
this.refresh_highlighted_tags();
return true;
}
cancel_populate_dropdown()
{
if(this.populate_dropdown_abort == null)
return;
this.populate_dropdown_abort.abort();
}
refresh_highlighted_tags()
{
let tags = helpers.split_search_tags(this.input_element.value);
var list = this.container.querySelector(".input-dropdown-list");
for(let tag_entry of list.querySelectorAll("[data-tag]"))
{
let tag = tag_entry.dataset.tag;
let tag_selected = tags.indexOf(tag) != -1;
helpers.set_class(tag_entry, "highlight", tag_selected);
}
}
// Add or remove tag from the tag search. This doesn't affect -tag searches.
toggle_tag(tag)
{
console.log("Toggle tag:", tag);
let tags = helpers.split_search_tags(this.input_element.value);
let idx = tags.indexOf(tag);
if(idx != -1)
tags.splice(idx, 1);
else
tags.push(tag);
this.input_element.value = tags.join(" ");
this.refresh_highlighted_tags();
// Navigate to the edited search immediately. Don't add these to history, since it
// spams navigation history.
helpers.set_page_url(page_manager.singleton().get_url_for_tag_search(this.input_element.value, ppixiv.location), false);
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r132/src/tag_search_dropdown_widget.js
`;
ppixiv.resources["src/recently_seen_illusts.js"] = `"use strict";
ppixiv.recently_seen_illusts = class
{
// Return the singleton, creating it if needed.
static get()
{
if(recently_seen_illusts._singleton == null)
recently_seen_illusts._singleton = new recently_seen_illusts();
return recently_seen_illusts._singleton;
};
constructor()
{
this.db = new key_storage("ppixiv-recent-illusts", { db_upgrade: this.db_upgrade });
settings.register_change_callback("no_recent_history", this.update_from_settings);
this.update_from_settings();
}
get enabled()
{
return !settings.get("no_recent_history");
}
update_from_settings = () =>
{
// If the user disables recent history, clear our storage.
if(!this.enabled)
{
console.log("Clearing history");
this.clear();
}
}
db_upgrade = (e) => {
// Create our object store with an index on last_seen.
let db = e.target.result;
let store = db.createObjectStore("ppixiv-recent-illusts");
store.createIndex("last_seen", "last_seen");
}
async add_illusts(media_ids)
{
// Clean up old illusts. We don't need to wait for this.
this.purge_old_illusts();
// Stop if we're not enabled.
if(!this.enabled)
return;
let time = Date.now();
let data = {};
let idx = 0;
for(let media_id of media_ids)
{
// Store thumbnail info with the image. Every data_source these days is able
// to fill in thumbnail data as part of the request, so we store the thumbnail
// info to be able to do the same in data_source.recent. We're called when
// a thumbnail is being displayed, so
let thumb_info = thumbnail_data.singleton().get_one_thumbnail_info(media_id);
if(thumb_info == null)
continue;
data[media_id] = {
// Nudge the time back slightly as we go, so illustrations earlier in the list will
// be treated as older. This causes them to sort earlier in the recent illustrations
// view. If we don't do this, they'll be displayed in an undefined order.
last_seen: time - idx,
thumb_info: thumb_info,
};
idx++;
}
// Batch write:
await this.db.multi_set(data);
}
async clear()
{
}
// Return media_ids for recently viewed illusts, most recent first.
async get_recent_media_ids()
{
return await this.db.db_op(async (db) => {
let store = this.db.get_store(db);
return await this.get_stored_illusts(store, "new");
});
}
// Return thumbnail data for the given media IDs if we have it.
async get_thumbnail_info(media_ids)
{
return await this.db.db_op(async (db) => {
let store = this.db.get_store(db);
// Load the thumbnail info in bulk.
let promises = {};
for(let media_id of media_ids)
promises[media_id] = key_storage.async_store_get(store, media_id);
await Promise.all(Object.values(promises));
let results = [];
for(let media_id of media_ids)
{
let entry = await promises[media_id];
if(entry && entry.thumb_info)
results.push(entry.thumb_info);
}
return results;
});
}
// Clean up IDs that haven't been seen in a while.
async purge_old_illusts()
{
await this.db.db_op(async (db) => {
let store = this.db.get_store(db);
let ids_to_delete = await this.get_stored_illusts(store, "old");
if(ids_to_delete.length == 0)
return;
await this.db.multi_delete(ids_to_delete);
});
}
// Get illusts in the database. If which is "new", return ones that we want to display
// to the user. If it's "old", return ones that should be deleted.
async get_stored_illusts(store, which="new")
{
// Read illustrations seen within the last hour, newest first.
let index = store.index("last_seen");
let starting_from = Date.now() - (60*60*1000);
let query = which == "new"? IDBKeyRange.lowerBound(starting_from):IDBKeyRange.upperBound(starting_from);
let cursor = index.openCursor(query, "prev");
let results = [];
for await (let entry of cursor)
results.push(entry.primaryKey);
return results;
}
// Clear history.
async clear()
{
return await this.db.clear();
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r132/src/recently_seen_illusts.js
`;
ppixiv.resources["src/tag_translations.js"] = `"use strict";
ppixiv.tag_translations = class
{
// Return the singleton, creating it if needed.
static get()
{
if(tag_translations._singleton == null)
tag_translations._singleton = new tag_translations();
return tag_translations._singleton;
};
constructor()
{
this.db = new key_storage("ppixiv-tag-translations");
}
// Store a list of tag translations.
//
// tag_list is a dictionary:
// {
// original_tag: {
// en: "english tag",
// }
// }
async add_translations_dict(tags)
{
let translations = [];
for(let tag of Object.keys(tags))
{
let tag_info = tags[tag];
let tag_translation = {};
for(let lang of Object.keys(tag_info))
{
if(tag_info[lang] == "")
continue;
tag_translation[lang] = tag_info[lang];
}
if(Object.keys(tag_translation).length > 0)
{
translations.push({
tag: tag,
translation: tag_translation,
});
}
}
this.add_translations(translations);
}
// Store a list of tag translations.
//
// tag_list is a list of
// {
// tag: "original tag",
// translation: {
// en: "english tag",
// },
// }
//
// This is the same format that Pixiv uses in newer APIs. Note that we currently only store
// English translations.
async add_translations(tag_list)
{
let data = {};
for(let tag of tag_list)
{
// If a tag has no keys and no romanization, skip it so we don't fill our database
// with useless entries.
if((tag.translation == null || Object.keys(tag.translation).length == 0) && tag.romaji == null)
continue;
// Remove empty translation values.
let translation = {};
for(let lang of Object.keys(tag.translation || {}))
{
let value = tag.translation[lang];
if(value != "")
translation[lang] = value;
}
// Store the tag data that we care about. We don't need to store post-specific info
// like "deletable".
let tag_info = {
tag: tag.tag,
translation: translation,
};
if(tag.romaji)
tag_info.romaji = tag.romaji;
data[tag.tag] = tag_info;
}
// Batch write:
await this.db.multi_set(data);
}
async get_tag_info(tags)
{
// If the user has disabled translations, don't return any.
if(settings.get("disable-translations"))
return {};
let result = {};
let translations = await this.db.multi_get(tags);
for(let i = 0; i < tags.length; ++i)
{
if(translations[i] == null)
continue;
result[tags[i]] = translations[i];
}
return result;
}
async get_translations(tags, language="en")
{
let info = await this.get_tag_info(tags);
let result = {};
for(let tag of tags)
{
if(info[tag] == null || info[tag].translation == null)
continue;
// Skip this tag if we don't have a translation for this language.
let translation = info[tag].translation[language];
if(translation == null)
continue;
result[tag] = translation;
}
return result;
}
// Given a tag search, return a translated search.
async translate_tag_list(tags, language)
{
// Pull out individual tags, removing -prefixes.
let split_tags = helpers.split_search_tags(tags);
let tag_list = [];
for(let tag of split_tags)
{
let [prefix, unprefixed_tag] = helpers.split_tag_prefixes(tag);
tag_list.push(unprefixed_tag);
}
// Get translations.
let translated_tags = await this.get_translations(tag_list, language);
// Put the search back together.
let result = [];
for(let one_tag of split_tags)
{
let prefix_and_tag = helpers.split_tag_prefixes(one_tag);
let prefix = prefix_and_tag[0];
let tag = prefix_and_tag[1];
if(translated_tags[tag])
tag = translated_tags[tag];
result.push(prefix + tag);
}
return result;
}
// A shortcut to retrieve one translation. If no translation is available, returns the
// original tag.
async get_translation(tag, language="en")
{
let translated_tags = await tag_translations.get().get_translations([tag], "en");
if(translated_tags[tag])
return translated_tags[tag];
else
return tag;
}
// Set the innerText of an element to tag, translating it if possible.
//
// This is async to look up the tag translation, but it's safe to release this
// without awaiting.
async set_translated_tag(element, tag)
{
let original_tag = tag;
element.dataset.tag = original_tag;
tag = await this.get_translation(tag);
// Stop if another call was made here while we were async.
if(element.dataset.tag != original_tag)
return;
element.innerText = tag;
}
}
// This updates the pp_tag_translations IDB store to ppixiv-tag-translations.
//
// The older database code kept the database open all the time. That's normal in every
// database in the world, except for IDB where it'll wedge everything (even the Chrome
// inspector window) if you try to change object stores. Read it out and write it to a
// new database, so users upgrading don't have to restart their browser to get tag translations
// back.
//
// This doesn't delete the old database, since for some reason that fires versionchange, which
// might make other tabs misbehave since they're not expecting it. We can add some code to
// clean up the old database later on when we can assume everybody has done this migration.
ppixiv.update_translation_storage = class
{
static run()
{
let update = new this();
update.update();
}
constructor()
{
this.name = "pp_tag_translations";
}
async db_op(func)
{
let db = await this.open_database();
try {
return await func(db);
} finally {
db.close();
}
}
open_database()
{
return new Promise((resolve, reject) => {
let request = indexedDB.open("ppixiv");
request.onsuccess = e => { resolve(e.target.result); };
request.onerror = e => { resolve(null); };
});
}
async_store_get(store)
{
return new Promise((resolve, reject) => {
let request = store.getAll();
request.onsuccess = e => resolve(e.target.result);
request.onerror = reject;
});
}
async update()
{
// Firefox is missing indexedDB.databases, so Firefox users get to wait for
// tag translations to repopulate.
if(!indexedDB.databases)
return;
// If the ppixiv-tag-translations database exists, assume this migration has already been done.
// First see if the old database exists and the new one doesn't.
let found = false;
for(let db of await indexedDB.databases())
{
if(db.name == "ppixiv-tag-translations")
return;
if(db.name == "ppixiv")
found = true;
}
if(!found)
return;
console.log("Migrating translation database");
// Open the old db.
return await this.db_op(async (db) => {
if(db == null)
return;
let transaction = db.transaction(this.name, "readonly");
let store = transaction.objectStore(this.name);
let results = await this.async_store_get(store);
let translations = [];
for(let result of results)
{
try {
if(!result.tag || !result.translation)
continue;
let data = {
tag: result.tag,
translation: { },
};
if(result.romaji)
data.romaji = result.romaji;
let empty = true;
for(let lang in result.translation)
{
let translated = result.translation[lang];
if(!translated)
continue;
data.translation[lang] = translated;
empty = false;
}
if(empty)
continue;
translations.push(data);
} catch(e) {
// Tolerate errors, in case there's weird junk in this database.
console.log("Error updating tag:", result);
}
}
await tag_translations.get().add_translations(translations);
});
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r132/src/tag_translations.js
`;
ppixiv.resources["src/thumbnail_data.js"] = `"use strict";
// This handles batch fetching data for thumbnails.
//
// We can load a bunch of images at once with illust_list.php. This isn't enough to
// display the illustration, since it's missing a lot of data, but it's enough for
// displaying thumbnails (which is what the page normally uses it for).
ppixiv.thumbnail_data = class
{
constructor()
{
// Cached data:
this.thumbnail_data = { };
this.quick_user_data = { };
this.user_profile_urls = {};
// IDs that we're currently requesting:
this.loading_ids = {};
};
// Return the singleton, creating it if needed.
static singleton()
{
if(thumbnail_data._singleton == null)
thumbnail_data._singleton = new thumbnail_data();
return thumbnail_data._singleton;
};
// Return true if all thumbs in media_ids have been loaded, or are currently loading.
//
// We won't start fetching IDs that aren't loaded.
are_all_media_ids_loaded_or_loading(media_ids)
{
for(let media_id of media_ids)
{
media_id = helpers.get_media_id_first_page(media_id);
if(this.thumbnail_data[media_id] == null && !this.loading_ids[media_id])
return false;
}
return true;
}
is_media_id_loaded_or_loading(media_id)
{
media_id = helpers.get_media_id_first_page(media_id);
if(helpers.is_media_id_local(media_id) && local_api.is_media_id_loading(media_id))
return true;
return this.thumbnail_data[media_id] != null || this.loading_ids[media_id];
}
// Return thumbnail data for media_id, or null if it's not loaded.
//
// The thumbnail data won't be loaded if it's not already available. Use get_thumbnail_info
// to load thumbnail data in batches.
get_one_thumbnail_info(media_id)
{
media_id = helpers.get_media_id_first_page(media_id);
return this.thumbnail_data[media_id];
}
// Return thumbnail data for media_ids, and start loading any requested IDs that aren't
// already loaded.
get_thumbnail_info(media_ids)
{
var result = {};
var needed_media_ids = [];
let local_media_ids = [];
for(let media_id of media_ids)
{
media_id = helpers.get_media_id_first_page(media_id);
let data = this.thumbnail_data[media_id];
if(data == null)
{
// Only load illust IDs.
let { type } = helpers.parse_media_id(media_id);
if(helpers.is_media_id_local(media_id))
{
local_media_ids.push(media_id);
continue;
}
if(type != "illust")
continue;
needed_media_ids.push(media_id);
continue;
}
result[media_id] = data;
}
// If any of these are local IDs, load them with local_api.
if(local_media_ids.length)
local_api.load_media_ids(local_media_ids);
// Load any thumbnail data that we didn't have.
if(needed_media_ids.length)
this.load_thumbnail_info(needed_media_ids);
return result;
}
// Load thumbnail info for the given list of IDs.
async load_thumbnail_info(media_ids, { force=false }={})
{
// Make a list of IDs that we're not already loading.
let illust_ids_to_load = [];
for(let media_id of media_ids)
{
media_id = helpers.get_media_id_first_page(media_id);
if(!force && this.loading_ids[media_id] != null)
continue;
illust_ids_to_load.push(helpers.parse_media_id(media_id).id);
this.loading_ids[media_id] = true;
}
if(illust_ids_to_load.length == 0)
return;
// There's also
//
// https://www.pixiv.net/ajax/user/user_id/profile/illusts?ids[]=1&ids[]=2&...
//
// which is used by newer pages. That's useful since it tells us whether each
// image is bookmarked. However, it doesn't tell us the user's name or profile image
// URL, and for some reason it's limited to a particular user. Hopefully they'll
// have an updated generic illustration lookup call if they ever update the
// regular search pages, and we can switch to it then.
var result = await helpers.rpc_get_request("/rpc/illust_list.php", {
illust_ids: illust_ids_to_load.join(","),
// Specifying this gives us 240x240 thumbs, which we want, rather than the 150x150
// ones we'll get if we don't (though changing the URL is easy enough too).
page: "discover",
// We do our own muting, but for some reason this flag is needed to get bookmark info.
exclude_muted_illusts: 1,
});
this.loaded_thumbnail_info(result, "illust_list");
}
// Get the user's profile picture URL, or a fallback if we haven't seen it.
get_profile_picture_url(user_id)
{
let result = this.user_profile_urls[user_id];
if(!result)
result = "https://s.pximg.net/common/images/no_profile.png";
return result;
}
// Get the mapping from /ajax/user/id/illusts/bookmarks to illust_list.php's keys.
get thumbnail_info_map_illust_list()
{
if(this._thumbnail_info_map_illust_list != null)
return this._thumbnail_info_map_illust_list;
this._thumbnail_info_map_illust_list = [
["illust_id", "id"],
["url", "url"],
["tags", "tags"],
["illust_user_id", "userId"],
["illust_width", "width"],
["illust_height", "height"],
["illust_type", "illustType"],
["illust_page_count", "pageCount"],
["illust_title", "illustTitle"],
["user_profile_img", "profileImageUrl"],
["user_name", "userName"],
// illust_list.php doesn't give the creation date.
[null, "createDate"],
];
return this._thumbnail_info_map_illust_list;
};
get thumbnail_info_map_ranking()
{
if(this._thumbnail_info_map_ranking != null)
return this._thumbnail_info_map_ranking;
this._thumbnail_info_map_ranking = [
["illust_id", "id"],
["url", "url"],
["tags", "tags"],
["user_id", "userId"],
["width", "width"],
["height", "height"],
["illust_type", "illustType"],
["illust_page_count", "pageCount"],
["title", "illustTitle"],
["profile_img", "profileImageUrl"],
["user_name", "userName"],
["illust_upload_timestamp", "createDate"],
];
return this._thumbnail_info_map_ranking;
};
// This is called when we have new thumbnail data available. thumb_result is
// an array of thumbnail items.
//
// This can come from a bunch of different places, which all return the same data, but
// each in a different way:
//
// name URL
// normal /ajax/user/id/illusts/bookmarks
// illust_list illust_list.php
// following bookmark_new_illust.php
// following search.php
// rankings ranking.php
//
// We map each of these to "normal".
//
// These have the same data, but for some reason everything has different names.
// Remap them to "normal", and check that all fields we expect exist, to make it
// easier to notice if something is wrong.
loaded_thumbnail_info = (thumb_result, source) =>
{
if(thumb_result.error)
return;
let remapped_thumb_info = null;
for(var thumb_info of thumb_result)
{
// Ignore entries with "isAdContainer". These aren't search results at all and just contain
// stuff we're not interested in.
if(thumb_info.isAdContainer)
continue;
if(source == "normal")
{
// The data is already in the format we want. The only change we make is
// to rename title to illustTitle, to match it up with illust info.
if(!("title" in thumb_info))
{
console.warn("Thumbnail info is missing key: title");
}
else
{
thumb_info.illustTitle = thumb_info.title;
delete thumb_info.title;
}
// Check that all keys we expect exist, and remove any keys we don't know about
// so we don't use them accidentally.
let thumbnail_info_map = this.thumbnail_info_map_ranking;
remapped_thumb_info = { };
for(let pair of thumbnail_info_map)
{
let key = pair[1];
if(!(key in thumb_info))
{
console.warn("Thumbnail info is missing key:", key);
continue;
}
remapped_thumb_info[key] = thumb_info[key];
}
if(!('bookmarkData' in thumb_info))
console.warn("Thumbnail info is missing key: bookmarkData");
else
{
remapped_thumb_info.bookmarkData = thumb_info.bookmarkData;
// See above.
if(remapped_thumb_info.bookmarkData != null)
delete remapped_thumb_info.bookmarkData.bookmarkId;
}
}
else if(source == "illust_list" || source == "rankings")
{
// Get the mapping for this mode.
let thumbnail_info_map =
source == "illust_list"? this.thumbnail_info_map_illust_list:
this.thumbnail_info_map_ranking;
remapped_thumb_info = { };
for(let pair of thumbnail_info_map)
{
let from_key = pair[0];
let to_key = pair[1];
if(from_key == null)
{
// This is just for illust_list createDate.
remapped_thumb_info[to_key] = null;
continue;
}
if(!(from_key in thumb_info))
{
console.warn("Thumbnail info is missing key:", from_key);
continue;
}
let value = thumb_info[from_key];
remapped_thumb_info[to_key] = value;
}
// Make sure that the illust IDs and user IDs are strings.
remapped_thumb_info.id = "" + remapped_thumb_info.id;
remapped_thumb_info.userId = "" + remapped_thumb_info.userId;
// Bookmark data is a special case.
//
// The old API has is_bookmarked: true, bookmark_id: "id" and bookmark_illust_restrict: 0 or 1.
// bookmark_id and bookmark_illust_restrict are omitted if is_bookmarked is false.
//
// The new API is a dictionary:
//
// bookmarkData = {
// bookmarkId: id,
// private: false
// }
//
// or null if not bookmarked.
//
// A couple sources of thumbnail data (bookmark_new_illust.php and search.php)
// don't return the bookmark ID. We don't use this (we only edit bookmarks from
// the image page, where we have full image data), so we omit bookmarkId from this
// data.
//
// Some pages return buggy results. /ajax/user/id/profile/all includes bookmarkData,
// but private is always false, so we can't tell if it's a private bookmark. This is
// a site bug that we can't do anything about (it affects the site too).
remapped_thumb_info.bookmarkData = null;
if(!('is_bookmarked' in thumb_info))
console.warn("Thumbnail info is missing key: is_bookmarked");
if(thumb_info.is_bookmarked)
{
remapped_thumb_info.bookmarkData = {
// See above.
// bookmarkId: thumb_info.bookmark_id,
private: thumb_info.bookmark_illust_restrict == 1,
};
}
// illustType can be a string in these instead of an int, so convert it.
remapped_thumb_info.illustType = parseInt(remapped_thumb_info.illustType);
if(source == "rankings")
{
// Rankings thumbnail info gives createDate as a Unix timestamp. Convert
// it to the same format as everything else.
let date = new Date(remapped_thumb_info.createDate*1000);
remapped_thumb_info.createDate = date.toISOString();
}
else if(source == "illust_list")
{
// This is the only source of thumbnail data that doesn't give createDate at
// all. This source is very rarely used now, so just fill in a bogus date.
remapped_thumb_info.createDate = new Date(0).toISOString();
}
}
else if(source == "internal")
{
remapped_thumb_info = thumb_info;
}
else
throw "Unrecognized source: " + source;
// "internal" is for thumbnail data which is already processed.
if(source != "internal")
{
// These fields are strings in some sources. Switch them to ints.
for(let key of ["pageCount", "width", "height"])
{
if(remapped_thumb_info[key] != null)
remapped_thumb_info[key] = parseInt(remapped_thumb_info[key]);
}
// Different APIs return different thumbnail URLs.
remapped_thumb_info.url = helpers.get_high_res_thumbnail_url(remapped_thumb_info.url);
// Create a list of thumbnail URLs.
remapped_thumb_info.previewUrls = [];
for(let page = 0; page < remapped_thumb_info.pageCount; ++page)
{
let url = helpers.get_high_res_thumbnail_url(remapped_thumb_info.url, page);
remapped_thumb_info.previewUrls.push(url);
}
// Remove url. Use previewUrl[0] instead
delete remapped_thumb_info.url;
// Rename .tags to .tagList, for consistency with the flat tag list in illust info.
remapped_thumb_info.tagList = remapped_thumb_info.tags;
delete remapped_thumb_info.tags;
}
thumb_info = remapped_thumb_info;
// Store the data.
this.add_thumbnail_info(thumb_info);
let media_id = helpers.illust_id_to_media_id(thumb_info.id);
delete this.loading_ids[media_id];
// This is really annoying: the profile picture is the only field that's present in thumbnail
// info but not illust info. We want a single basic data set for both, so that can't include
// the profile picture. But, we do want to display it in places where we can't get user
// info (muted search results), so store it separately.
if(thumb_info.profileImageUrl)
{
let profile_image_url = thumb_info.profileImageUrl;
profile_image_url = profile_image_url.replace("_50.", "_170."),
this.user_profile_urls[thumb_info.userId] = profile_image_url;
delete thumb_info.profileImageUrl;
}
}
// Broadcast that we have new thumbnail data available.
window.dispatchEvent(new Event("thumbnailsloaded"));
};
// Store thumbnail info.
add_thumbnail_info(thumb_info)
{
let media_id = helpers.illust_id_to_media_id(thumb_info.id);
this.thumbnail_data[media_id] = thumb_info;
}
is_muted(thumb_info)
{
if(muting.singleton.is_muted_user_id(thumb_info.illust_user_id))
return true;
if(muting.singleton.any_tag_muted(thumb_info.tags))
return true;
return false;
}
// This is a simpler form of thumbnail data for user info. This is just the bare minimum
// info we need to be able to show a user thumbnail on the search page. This is used when
// we're displaying lots of users in search results.
//
// We can get this info from two places, the following page (data_source_follows) and the
// user recommendations page (data_source_discovery_users). Of course, since Pixiv never
// does anything the same way twice, they have different formats.
//
// The only info we need is:
// userId
// userName
// profileImageUrl
add_quick_user_data(source_data, source)
{
let data = null;
let id = source_data.userId;
if(source == "following")
{
data = {
userId: source_data.userId,
userName: source_data.userName,
profileImageUrl: source_data.profileImageUrl,
};
}
else if(source == "recommendations")
{
data = {
userId: source_data.userId,
userName: source_data.name,
profileImageUrl: source_data.imageBig,
};
}
else if(source == "users_bookmarking_illust" || source == "user_search")
{
data = {
userId: source_data.user_id,
userName: source_data.user_name,
profileImageUrl: source_data.profile_img,
};
}
else
throw "Unknown source: " + source;
this.quick_user_data[data.userId] = data;
}
get_quick_user_data(user_id)
{
return this.quick_user_data[user_id];
}
thumbnail_info_keys = [
"id",
"illustType",
"illustTitle",
"pageCount",
"userId",
"userName",
"width",
"height",
"previewUrls",
"bookmarkData",
"createDate",
"tagList",
];
// Return illust info or thumbnail data, whichever is available. If we don't have
// either, read full illust info. If we have both, return illust info.
//
// This is used when we're displaying info for a single image, and the caller only
// needs thumbnail data. It allows us to use either thumbnail data or illust info,
// so we can usually return the data immediately.
//
// If it isn't available and we need to load it, we load illust info instead of thumbnail
// data, since it takes a full API request either way.
async get_or_load_illust_data(media_id)
{
// First, see if we have full illust info. Prefer to use it over thumbnail info
// if we have it, so full info is available. If we don't, see if we have thumbnail
// info.
let data = image_data.singleton().get_media_info_sync(media_id);
if(data == null)
data = thumbnail_data.singleton().get_one_thumbnail_info(media_id);
// If we don't have either, load the image info.
if(data == null)
data = await image_data.singleton().get_media_info(media_id);
this._check_illust_data(data);
return data;
}
// A sync version of get_or_load_illust_data. This doesn't load data if it
// isn't available.
get_illust_data_sync(media_id)
{
// First, see if we have full illust info. Prefer to use it over thumbnail info
// if we have it, so full info is available. If we don't, see if we have thumbnail
// info.
let data = image_data.singleton().get_media_info_sync(media_id);
if(data == null)
data = thumbnail_data.singleton().get_one_thumbnail_info(media_id);
this._check_illust_data(data);
return data;
}
// Check the result of get_or_load_illust_data. We always expect all keys in
// thumbnail_info_keys to be included, regardless of where the data came from.
_check_illust_data(illust_data)
{
if(illust_data == null)
return;
for(let key of this.thumbnail_info_keys)
{
if(!(key in illust_data))
{
console.warn(\`Missing key \${key} for early data\`, illust_data);
continue;
}
}
}
// Update illustration data in both thumbnail info and illust info.
//
// This is used in places that use get_or_load_illust_data to get thumbnail
// info, and then need to save changes to it. Update both sources.
//
// This can't update tags.
update_illust_data(media_id, data)
{
media_id = helpers.get_media_id_first_page(media_id);
let update_data = (update, keys) => {
for(let key of keys)
{
if(!(key in data))
continue;
console.assert(key != "tags");
update[key] = data[key];
}
};
let thumb_data = thumbnail_data.singleton().get_one_thumbnail_info(media_id);
if(thumb_data)
update_data(thumb_data, this.thumbnail_info_keys);
let illust_info = image_data.singleton().get_media_info_sync(media_id);
if(illust_info != null)
update_data(illust_info, this.thumbnail_info_keys);
image_data.singleton().call_illust_modified_callbacks(media_id);
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r132/src/thumbnail_data.js
`;
ppixiv.resources["src/page_manager.js"] = `"use strict";
// This handles:
//
// - Keeping track of whether we're active or not. If we're inactive, we turn off
// and let the page run normally.
// - Storing state in the address bar.
//
// We're active by default on illustration pages, and inactive by default on others.
//
// If we're active, we'll store our state in the hash as "#ppixiv/...". The start of
// the hash will always be "#ppixiv", so we can tell it's our data. If we're on a page
// where we're inactive by default, this also remembers that we've been activated.
//
// If we're inactive on a page where we're active by default, we'll always put something
// other than "#ppixiv" in the address bar. It doesn't matter what it is. This remembers
// that we were deactivated, and remains deactivated even if the user clicks an anchor
// in the page that changes the hash.
//
// If we become active or inactive after the page loads, we refresh the page.
//
// We have two sets of query parameters: args stored in the URL query, and args stored in
// the hash. For example, in:
//
// https://www.pixiv.net/bookmark.php?p=2#ppixiv?illust_id=1234
//
// our query args are p=2, and our hash args are illust_id=1234. We use query args to
// store state that exists in the underlying page, and hash args to store state that
// doesn't, so the URL remains valid for the actual Pixiv page if our UI is turned off.
ppixiv.page_manager = class
{
constructor()
{
window.addEventListener("popstate", this.window_popstate, true);
this.data_sources_by_canonical_url = {};
this.active = this._active_internal();
};
// Return the singleton, creating it if needed.
static singleton()
{
if(page_manager._singleton == null)
page_manager._singleton = new page_manager();
return page_manager._singleton;
};
// Return the data source for a URL, or null if the page isn't supported.
get_data_source_for_url(url)
{
// url is usually document.location, which for some reason doesn't have .searchParams.
var url = new unsafeWindow.URL(url);
url = helpers.get_url_without_language(url);
let first_part = helpers.get_page_type_from_url(url);
if(first_part == "artworks")
{
let args = new helpers.args(url);
if(args.hash.get("manga"))
return data_sources.manga;
else
return data_sources.current_illust;
}
else if(first_part == "users")
{
// This is one of:
//
// /users/12345
// /users/12345/artworks
// /users/12345/illustrations
// /users/12345/manga
// /users/12345/bookmarks
// /users/12345/following
//
// All of these except for bookmarks are handled by data_sources.artist.
let mode = helpers.get_path_part(url, 2);
if(mode == "following")
return data_sources.follows;
if(mode != "bookmarks")
return data_sources.artist;
// Handle a special case: we're called by early_controller just to find out if
// the current page is supported or not. This happens before window.global_data
// exists, so we can't check if we're viewing our own bookmarks or someone else's.
// In this case we don't need to, since the caller just wants to see if we return
// a data source or not.
if(window.global_data == null)
return data_sources.bookmarks;
// If show-all=0 isn't in the hash, and we're not viewing someone else's bookmarks,
// we're viewing all bookmarks, so use data_sources.bookmarks_merged. Otherwise,
// use data_sources.bookmarks.
var args = new helpers.args(url);
var user_id = helpers.get_path_part(url, 1);
if(user_id == null)
user_id = window.global_data.user_id;
var viewing_own_bookmarks = user_id == window.global_data.user_id;
var both_public_and_private = viewing_own_bookmarks && args.hash.get("show-all") != "0";
return both_public_and_private? data_sources.bookmarks_merged:data_sources.bookmarks;
}
else if(url.pathname == "/new_illust.php" || url.pathname == "/new_illust_r18.php")
return data_sources.new_illust;
else if(url.pathname == "/bookmark_new_illust.php" || url.pathname == "/bookmark_new_illust_r18.php")
return data_sources.new_works_by_following;
else if(url.pathname == "/history.php")
return data_sources.recent;
else if(first_part == "tags")
return data_sources.search;
else if(url.pathname == "/discovery")
return data_sources.discovery;
else if(url.pathname == "/discovery/users")
return data_sources.discovery_users;
else if(url.pathname == "/bookmark_detail.php")
{
// If we've added "recommendations" to the hash info, this was a recommendations link.
let args = new helpers.args(url);
if(args.hash.get("recommendations"))
return data_sources.related_illusts;
else
return data_sources.related_favorites;
}
else if(url.pathname == "/ranking.php")
return data_sources.rankings;
else if(url.pathname == "/search_user.php")
return data_sources.search_users;
else if(url.pathname.startsWith("/request/complete"))
return data_sources.completed_requests;
else if(url.pathname.startsWith(local_api.path))
return data_sources.vview;
else
return null;
};
// Create the data source for a given URL.
//
// If we've already created a data source for this URL, the same one will be
// returned.
//
// If force is true, we'll always create a new data source, replacing any
// previously created one.
create_data_source_for_url(url, force)
{
var data_source_class = this.get_data_source_for_url(url);
if(data_source_class == null)
{
console.error("Unexpected path:", url.pathname);
return;
}
// Canonicalize the URL to see if we already have a data source for this URL.
let canonical_url = data_source_class.get_canonical_url(url);
// console.log("url", url.toString(), "becomes", canonical_url);
if(!force && canonical_url in this.data_sources_by_canonical_url)
{
// console.log("Reusing data source for", url.toString());
return this.data_sources_by_canonical_url[canonical_url];
}
// console.log("Creating new data source for", url.toString());
var source = new data_source_class(url.href);
this.data_sources_by_canonical_url[canonical_url] = source;
return source;
}
// If we have the given data source cached, discard it, so it'll be recreated
// the next time it's used.
discard_data_source(data_source)
{
let urls_to_remove = [];
for(let url in this.data_sources_by_canonical_url)
{
if(this.data_sources_by_canonical_url[url] === data_source)
urls_to_remove.push(url);
}
for(let url of urls_to_remove)
delete this.data_sources_by_canonical_url[url];
}
// Return true if it's possible for us to be active on this page.
available_for_url(url)
{
// We support the page if it has a data source.
return this.get_data_source_for_url(url) != null;
};
window_popstate = (e) =>
{
var currently_active = this._active_internal();
if(this.active == currently_active)
return;
// Stop propagation, so other listeners don't see this. For example, this prevents
// the thumbnail viewer from turning on or off as a result of us changing the hash
// to "#no-ppixiv".
e.stopImmediatePropagation();
if(this.active == currently_active)
return;
this.store_ppixiv_disabled(!currently_active);
console.log("Active state changed");
// The URL has changed and caused us to want to activate or deactivate. Reload the
// page.
//
// We'd prefer to reload with cache, like a regular navigation, but Firefox seems
// to reload without cache no matter what we do, even though document.location.reload
// is only supposed to bypass cache on reload(true). There doesn't seem to be any
// reliable workaround.
document.location.reload();
}
store_ppixiv_disabled(disabled)
{
// Remember that we're enabled or disabled in this tab.
if(disabled)
window.sessionStorage.ppixiv_disabled = 1;
else
delete window.sessionStorage.ppixiv_disabled;
}
// Return true if we're active by default on the current page.
active_by_default()
{
if(ppixiv.native)
return true;
// If the disabled-by-default setting is enabled, disable by default until manually
// turned on.
if(settings.get("disabled-by-default"))
return false;
// If this is set, the user clicked the "return to Pixiv" button. Stay disabled
// in this tab until we're reactivated.
if(window.sessionStorage.ppixiv_disabled)
return false;
return this.available_for_url(ppixiv.location);
};
// Return true if we're currently active.
//
// This is cached at the start of the page and doesn't change unless the page is reloaded.
_active_internal()
{
// If the hash is empty, use the default.
if(ppixiv.location.hash == "")
return this.active_by_default();
// If we have a hash and it's not #ppixiv, then we're explicitly disabled. If we
// # do have a #ppixiv hash, we're explicitly enabled.
//
// If we're explicitly enabled but aren't actually available, we're disabled. This
// makes sure we don't break pages if we accidentally load them with a #ppixiv hash,
// or if we remove support for a page that people have in their browser session.
return helpers.is_ppixiv_url(ppixiv.location) && this.available_for_url(ppixiv.location);
};
// Given a list of tags, return the URL to use to search for them. This differs
// depending on the current page.
get_url_for_tag_search(tags, url)
{
url = helpers.get_url_without_language(url);
let type = helpers.get_page_type_from_url(url);
if(type == "tags")
{
// If we're on search already, just change the search tag, so we preserve other settings.
// /tags/tag/artworks -> /tag/new tag/artworks
let parts = url.pathname.split("/");
parts[2] = encodeURIComponent(tags);
url.pathname = parts.join("/");
} else {
// If we're not, change to search and remove the rest of the URL.
url = new URL("/tags/" + encodeURIComponent(tags) + "/artworks#ppixiv", url);
}
// Don't include things like the current page in the URL.
url = data_source.remove_ignored_url_parts(url);
return url;
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r132/src/page_manager.js
`;
ppixiv.resources["src/remove_link_interstitial.js"] = `"use strict";
// Fix Pixiv's annoying link interstitials.
//
// External links on Pixiv go through a pointless extra page. This seems like
// they're trying to mask the page the user is coming from, but that's what
// rel=noreferrer is for. Search for these links and fix them.
//
// This also removes target=_blank, which is just obnoxious. If I want a new
// tab I'll middle click.
(function() {
// Ignore iframes.
if(window.top != window.self)
return;
var observer = new window.MutationObserver(function(mutations) {
for(var mutation of mutations) {
if(mutation.type != 'childList')
return;
for(var node of mutation.addedNodes)
{
if(node.querySelectorAll == null)
continue;
helpers.fix_pixiv_links(node);
}
}
});
window.addEventListener("DOMContentLoaded", function() {
helpers.fix_pixiv_links(document.body);
observer.observe(window.document.body, {
// We could listen to attribute changes so we'll fix links that have their
// target changed after they're added to the page, but unless there are places
// where that's needed, let's just listen to node additions so we don't trigger
// too often.
attributes: false,
childList: true,
subtree: true
});
}, true);
})();
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r132/src/remove_link_interstitial.js
`;
ppixiv.resources["src/image_preloading.js"] = `"use strict";
// Handle preloading images.
//
// If we have a reasonably fast connection and the site is keeping up, we can just preload
// blindly and let the browser figure out priorities. However, if we preload too aggressively
// for the connection and loads start to back up, it can cause image loading to become delayed.
// For example, if we preload 100 manga page images, and then back out of the page and want to
// view something else, the browser won't load anything else until those images that we no
// longer need finish loading.
//
// image_preloader is told the media_id that we're currently showing, and the ID that we want
// to speculatively load. We'll run loads in parallel, giving the current image's resources
// priority and cancelling loads when they're no longer needed.
// A base class for fetching a single resource:
class preloader
{
constructor()
{
this.abort_controller = new AbortController();
}
// Cancel the fetch.
cancel()
{
if(this.abort_controller == null)
return;
this.abort_controller.abort();
this.abort_controller = null;
}
}
// Load a single image with
:
class img_preloader extends preloader
{
constructor(url, onerror=null)
{
super();
this.url = url;
this.onerror = onerror;
console.assert(url);
}
// Start the fetch. This should only be called once.
async start()
{
if(this.url == null)
return;
let img = document.createElement("img");
img.src = this.url;
let result = await helpers.wait_for_image_load(img, this.abort_controller.signal);
if(result == "failed" && this.onerror)
this.onerror();
}
}
// Load a resource with fetch.
class fetch_preloader extends preloader
{
constructor(url)
{
super();
this.url = url;
console.assert(url);
}
async start()
{
if(this.url == null)
return;
let request = helpers.send_pixiv_request({
url: this.url,
method: "GET",
signal: this.abort_controller.signal,
});
// Wait for the body to download before completing. Ignore errors here (they'll
// usually be cancellations).
try {
request = await request;
await request.text();
} catch(e) { }
}
}
// The image preloader singleton.
ppixiv.image_preloader = class
{
// Return the singleton, creating it if needed.
static get singleton()
{
if(image_preloader._singleton == null)
image_preloader._singleton = new image_preloader();
return image_preloader._singleton;
};
constructor()
{
// The _preloader objects that we're currently running.
this.preloads = [];
// A queue of URLs that we've finished preloading recently. We use this to tell if
// we don't need to run a preload.
this.recently_preloaded_urls = [];
}
// Set the media_id the user is currently viewing. If media_id is null, the user isn't
// viewing an image (eg. currently viewing thumbnails).
async set_current_image(media_id)
{
if(this.current_media_id == media_id)
return;
this.current_media_id = media_id;
this.current_illust_info = null;
await this.guess_preload(media_id);
if(this.current_media_id == null)
return;
// Get the image data. This will often already be available.
let illust_info = await image_data.singleton().get_media_info(this.current_media_id);
// Stop if the illust was changed while we were loading.
if(this.current_media_id != media_id)
return;
// Store the illust_info for current_media_id.
this.current_illust_info = illust_info;
this.check_fetch_queue();
}
// Set the media_id we want to speculatively load, which is the next or previous image in
// the current search. If media_id is null, we don't want to speculatively load anything.
async set_speculative_image(media_id)
{
if(this.speculative_media_id == media_id)
return;
this.speculative_media_id = media_id;
this.speculative_illust_info = null;
if(this.speculative_media_id == null)
return;
// Get the image data. This will often already be available.
let illust_info = await image_data.singleton().get_media_info(this.speculative_media_id);
if(this.speculative_media_id != media_id)
return;
// Stop if the illust was changed while we were loading.
if(this.speculative_media_id != media_id)
return;
// Store the illust_info for current_media_id.
this.speculative_illust_info = illust_info;
this.check_fetch_queue();
}
// See if we need to start or stop preloads. We do this when we have new illustration info,
// and when a fetch finishes.
check_fetch_queue()
{
// console.log("check queue:", this.current_illust_info != null, this.speculative_illust_info != null);
// Make a list of fetches that we want to be running, in priority order.
let wanted_preloads = [];
if(this.current_illust_info != null)
wanted_preloads = wanted_preloads.concat(this.create_preloaders_for_illust(this.current_illust_info, this.current_media_id));
if(this.speculative_illust_info != null)
wanted_preloads = wanted_preloads.concat(this.create_preloaders_for_illust(this.speculative_illust_info, this.speculative_media_id));
// Remove all preloads from wanted_preloads that we've already finished recently.
let filtered_preloads = [];
for(let preload of wanted_preloads)
{
if(this.recently_preloaded_urls.indexOf(preload.url) == -1)
filtered_preloads.push(preload);
}
// If we don't want any preloads, stop. If we have any running preloads, let them continue.
if(filtered_preloads.length == 0)
{
// console.log("Nothing to do");
return;
}
// Discard preloads beyond the number we want to be running. If we're loading more than this,
// we'll start more as these finish.
let concurrent_preloads = 5;
filtered_preloads.splice(concurrent_preloads);
// console.log("Preloads:", filtered_preloads.length);
// If any preload in the list is running, stop. We only run one preload at a time, so just
// let it finish.
let any_preload_running = false;
for(let preload of filtered_preloads)
{
let active_preload = this._find_active_preload_by_url(preload.url);
if(active_preload != null)
return;
}
// No preloads are running, so start the highest-priority preload.
//
// updated_preload_list allows us to run multiple preloads at a time, but we currently
// run them in serial.
let unwanted_preloads;
let updated_preload_list = [];
for(let preload of filtered_preloads)
{
// Start this preload.
// console.log("Start preload:", preload.url);
let promise = preload.start();
let aborted = false;
promise.catch((e) => {
if(e.name == "AbortError")
aborted = true;
});
promise.finally(() => {
// Add the URL to recently_preloaded_urls, so we don't try to preload this
// again for a while. We do this even on error, so we don't try to load
// failing images repeatedly.
//
// Don't do this if the request was aborted, since that just means the user
// navigated away.
if(!aborted)
{
this.recently_preloaded_urls.push(preload.url);
this.recently_preloaded_urls.splice(0, this.recently_preloaded_urls.length - 1000);
}
// When the preload finishes (successful or not), remove it from the list.
let idx = this.preloads.indexOf(preload);
if(idx == -1)
{
console.error("Preload finished, but we weren't running it:", preload.url);
return;
}
this.preloads.splice(idx, 1);
// See if we need to start another preload.
this.check_fetch_queue();
});
updated_preload_list.push(preload);
break;
}
// Cancel preloads in this.preloads that aren't in updated_preload_list. These are
// preloads that we either don't want anymore, or which have been pushed further down
// the priority queue and overridden.
for(let preload of this.preloads)
{
if(updated_preload_list.indexOf(preload) != -1)
continue;
console.log("Cancelling preload:", preload.url);
preload.cancel();
// Preloads stay in the list until the cancellation completes.
updated_preload_list.push(preload);
}
this.preloads = updated_preload_list;
}
// Return the preloader if we're currently preloading url.
_find_active_preload_by_url(url)
{
for(let preload of this.preloads)
if(preload.url == url)
return preload;
return null;
}
// Return an array of preloaders to load resources for the given illustration.
create_preloaders_for_illust(illust_data, media_id)
{
// Don't precache muted images.
if(muting.singleton.any_tag_muted(illust_data.tagList))
return [];
if(muting.singleton.is_muted_user_id(illust_data.userId))
return [];
// If this is an animation, preload the ZIP.
if(illust_data.illustType == 2 && !helpers.is_media_id_local(media_id))
{
let results = [];
results.push(new fetch_preloader(illust_data.ugoiraMetadata.originalSrc));
// Preload the original image too, which viewer_ugoira displays if the ZIP isn't
// ready yet.
results.push(new img_preloader(illust_data.urls.original));
return results;
}
// If this is a video, preload the poster.
if(illust_data.illustType == "video")
return [new img_preloader(illust_data.mangaPages[0].urls.poster) ];
// Otherwise, preload the images. Preload thumbs first, since they'll load
// much faster.
let results = [];
for(let url of illust_data.previewUrls)
results.push(new img_preloader(url));
// Preload the requested page.
let page = helpers.parse_media_id(media_id).page;
if(page < illust_data.mangaPages.length)
results.push(new img_preloader(illust_data.mangaPages[page].urls.original));
// Preload the remaining pages.
for(let p = 0; p < illust_data.mangaPages.length; ++p)
{
if(p == page)
continue;
results.push(new img_preloader(illust_data.mangaPages[p].urls.original));
}
return results;
}
// Try to start a guessed preload.
//
// This uses guess_image_url to try to figure out the image URL earlier. Normally
// we have to wait for the image info request to finish before we have the image URL
// to start loading, but if we can guess the URL correctly then we can start loading
// it immediately.
//
// If media_id is null, stop any running guessed preload.
async guess_preload(media_id)
{
// See if we can guess the image's URL from previous info, or if we can figure it
// out from another source.
let guessed_url = null;
if(media_id != null)
{
guessed_url = await guess_image_url.get.guess_url(media_id);
if(this.guessed_preload && this.guessed_preload.url == guessed_url)
return;
}
// Cancel any previous guessed preload.
if(this.guessed_preload)
{
this.guessed_preload.cancel();
this.guessed_preload = null;
}
// Start the new guessed preload.
if(guessed_url)
{
this.guessed_preload = new img_preloader(guessed_url, () => {
// The image load failed. Let guessed_preload know.
console.info("Guessed image load failed");
guess_image_url.get.guessed_url_incorrect(media_id);
});
this.guessed_preload.start();
}
}
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r132/src/image_preloading.js
`;
ppixiv.resources["src/whats_new.js"] = `"use strict";
// This should be inside whats_new, but Firefox is in the dark ages and doesn't support class fields.
let _update_history = [
{
version: 132,
text: \`
Improved following users, allowing changing a follow to public or private and
adding support for follow tags.
\`,
},
{
version: 129,
text: \`
Added a new way of viewing manga posts.
You can now view manga posts in search results. Click the page count in the corner of
thumbnails to show all manga pages. You can also click open_in_full
in the top menu to expand everything, or turn it on everywhere in settings.
\`,
}, {
version: 126,
text: \`
Muted tags and users can now be edited from the preferences menu.
Any number of tags can be muted. If you don't have Premium, mutes will be
saved to the browser instead of to your Pixiv account.
\`,
}, {
version: 123,
text: \`
Added support for viewing completed requests.
Disabled light mode for now. It's a pain to maintain two color schemes and everyone
is probably using dark mode anyway. If you really want it, let me know on GitHub.
\`,
},
{
version: 121,
text: \`
Added a slideshow mode. Click
wallpaper at the top.
Added an option to pan images as they're viewed.
Double-clicking images now toggles fullscreen.
The background is now fully black when viewing an image, for better contrast. Other screens are still dark grey.
Added an option to bookmark privately by default, such as when bookmarking by selecting
a bookmark tag.
Reworked the animation UI.
\`,
},
{
version: 117,
text: \`
Added Linked Tabs. Enable linked tabs in preferences to show images
on more than one monitor as they're being viewed (try it with a portrait monitor).
Showing the popup menu when Ctrl is pressed is now optional.
\`,
},
{
version: 112,
text: \`
Added Send to Tab to the context menu, which allows quickly sending an image to
another tab.
Added a More Options dropdown to the popup menu. This includes some things that
were previously only available from the hover UI. Send to Tab is also in here.
Disabled the "Similar Illustrations" lightbulb button on thumbnails. It can now be
accessed from the popup menu, along with a bunch of other ways to get image recommendations.
\`
},
{
version: 110,
text: \`
Added Quick View. This views images immediately when the mouse is pressed,
and images can be panned with the same press.
This can be enabled in preferences, and may become the default in a future release.
\`
},
{
version: 109,
boring: true,
text: \`Added a visual marker on thumbnails to show the last image you viewed.\`
},
{
version: 104,
text:
"Bookmarks can now be shuffled, to view them in random order. " +
"
" +
"Bookmarking an image now always likes it, like Pixiv's mobile app. " +
"(Having an option for this didn't seem useful.)" +
"
" +
"Added a Recent History search, to show recent search results. This can be turned " +
"off in settings."
},
{
version: 102,
boring: true,
text:
"Animations now start playing much faster."
},
{
version: 100,
text:
"Enabled auto-liking images on bookmark by default, to match the Pixiv mobile apps. " +
"If you've previously changed this in preferences, your setting should stay the same." +
"
" +
"Added a download button for the current page when viewing manga posts."
},
{
version: 97,
text:
"Holding Ctrl now displays the popup menu, to make it easier to use for people on touchpads.
" +
"
" +
"Keyboard hotkeys reworked, and can now be used while hovering over search results.
" +
"
" +
"Ctrl-V - like image\\n" +
"Ctrl-B - bookmark\\n" +
"Ctrl-Alt-B - bookmark privately\\n" +
"Ctrl-Shift-B - remove bookmark\\n" +
"Ctrl-Alt-Shift-M - add bookmark tag\\n" +
"Ctrl-F - follow\\n" +
"Ctrl-Alt-F - follow privately\\n" +
"Ctrl-Shift-F - unfollow\\n" +
"
"
},
{
version: 89,
text:
"Reworked zooming to make it more consistent and easier to use." +
"
" +
"You can now zoom images to 100% to view them at actual size."
},
{
version: 82,
text:
"Press Ctrl-Alt-Shift-B to bookmark an image with a new tag."
},
{
version: 79,
text:
"Added support for viewing new R-18 works by followed users."
},
{
version: 77,
text:
"Added user searching." +
"
" +
"Commercial/subscription links in user profiles (Fanbox, etc.) now use a different icon."
},
{
version: 74,
text:
"Viewing your followed users by tag is now supported." +
"
" +
"You can now view other people who bookmarked an image, to see what else they've bookmarked. " +
"This is available from the top-left hover menu."
},
{
version: 72,
text:
"The followed users page now remembers which page you were on if you reload the page, to make " +
"it easier to browse your follows if you have a lot of them." +
"
" +
"Returning to followed users now flashes who you were viewing like illustrations do," +
"to make it easier to pick up where you left off." +
"
" +
"Added a browser back button to the context menu, to make navigation easier in fullscreen " +
"when the browser back button isn't available."
},
{
version: 68,
text:
"You can now go to either the first manga page or the page list from search results. " +
"Click the image to go to the first page, or the page count to go to the page list." +
"
" +
"Our button is now in the bottom-left when we're disabled, since Pixiv now puts a menu " +
"button in the top-left and we were covering it up."
},
{
version: 65,
text:
"Bookmark viewing now remembers which page you were on if the page is reloaded." +
"
"+
"Zooming is now in smaller increments, to make it easier to zoom to the level you want."
},
{
version: 57,
text:
"Search for similar artists. Click the recommendations item at the top of the artist page, " +
"or in the top-left when viewing an image." +
"
"+
"You can also now view suggested artists."
},
{
version: 56,
text:
"Tag translations are now supported. This can be turned off in preferences. " +
"
" +
"Added quick tag search editing. After searching for a tag, click the edit button " +
"to quickly add and remove tags."
},
{
version: 55,
text:
"The \\"original\\" view is now available in Rankings." +
"
" +
"Hiding the mouse cursor can now be disabled in preferences.",
},
{
version: 49,
text:
"Add \\"Hover to show UI\\" preference, which is useful for low-res monitors."
},
{
version: 47,
text:
"You can now view the users you're following with \\"Followed Users\\". This shows each " +
"user's most recent post."
},
];
ppixiv.whats_new = class extends ppixiv.dialog_widget
{
// Return the newest revision that exists in history. This is always the first
// history entry.
static latest_history_revision()
{
return _update_history[0].version;
}
// Return the latest interesting history entry.
//
// We won't highlight the "what's new" icon for boring history entries.
static latest_interesting_history_revision()
{
for(let history of _update_history)
{
if(history.boring)
continue;
return history.version;
}
// We shouldn't get here.
throw Error("Couldn't find anything interesting");
}
constructor({...options})
{
super({...options, visible: true, template: \`
\`});
this.refresh();
this.container.querySelector(".close-button").addEventListener("click", (e) => { this.visible = false;; });
// Close if the container is clicked, but not if something inside the container is clicked.
this.container.addEventListener("click", (e) => {
if(e.target != this.container)
return;
this.visible = false;
});
// Hide on any state change.
window.addEventListener("popstate", (e) => {
this.visible = false;
});
}
refresh()
{
let items_box = this.container.querySelector(".items");
// Not really needed, since our contents never change
helpers.remove_elements(items_box);
for(let update of _update_history)
{
let entry = this.create_template({name: "item", html: \`
\`});
entry.querySelector(".rev").innerText = "r" + update.version;
entry.querySelector(".text").innerHTML = update.text;
items_box.appendChild(entry);
}
}
visibility_changed()
{
super.visibility_changed();
if(!this.visible)
{
// Remove the widget when it's hidden.
this.container.remove();
}
}
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r132/src/whats_new.js
`;
ppixiv.resources["src/send_image.js"] = `"use strict";
// This handles sending images from one tab to another.
ppixiv.SendImage = class
{
// This is a singleton, so we never close this channel.
static send_image_channel = new BroadcastChannel("ppixiv:send-image");
// A UUID we use to identify ourself to other tabs:
static tab_id = this.create_tab_id();
static tab_id_tiebreaker = Date.now()
static create_tab_id(recreate=false)
{
// If we have a saved tab ID, use it.
if(!recreate && sessionStorage.ppixivTabId)
return sessionStorage.ppixivTabId;
// Make a new ID, and save it to the session. This helps us keep the same ID
// when we're reloaded.
sessionStorage.ppixivTabId = helpers.create_uuid();
return sessionStorage.ppixivTabId;
}
static known_tabs = {};
static initialized = false;
static init()
{
if(this.initialized)
return;
this.initialized = true;
this.pending_movement = [0, 0];
this.listeners = {};
window.addEventListener("unload", this.window_onunload);
// Let other tabs know when the info we send in tab info changes. For resize, delay this
// a bit so we don't spam broadcasts while the user is resizing the window.
window.addEventListener("resize", (e) => {
if(this.broadcast_info_after_resize_timer != -1)
clearTimeout(this.broadcast_info_after_resize_timer);
this.broadcast_info_after_resize_timer = setTimeout(this.broadcast_tab_info, 250);
});
window.addEventListener("visibilitychange", this.broadcast_tab_info);
document.addEventListener("windowtitlechanged", this.broadcast_tab_info);
// Send on window focus change, so we update things like screenX/screenY that we can't
// monitor.
window.addEventListener("focus", this.broadcast_tab_info);
window.addEventListener("blur", this.broadcast_tab_info);
window.addEventListener("popstate", this.broadcast_tab_info);
// If we gain focus while quick view is active, finalize the image. Virtual
// history isn't meant to be left enabled, since it doesn't interact with browser
// history.
window.addEventListener("focus", (e) => {
let args = ppixiv.helpers.args.location;
if(args.hash.has("temp-view"))
{
console.log("Finalizing quick view image because we gained focus");
args.hash.delete("virtual");
args.hash.delete("temp-view");
ppixiv.helpers.set_page_url(args, false, "navigation");
}
});
SendImage.send_image_channel.addEventListener("message", this.received_message);
this.broadcast_tab_info();
this.query_tabs();
}
static messages = new EventTarget();
static add_message_listener(message, func)
{
if(!this.listeners[message])
this.listeners[message] = [];
this.listeners[message].push(func);
}
// If we're sending an image and the page is unloaded, try to cancel it. This is
// only registered when we're sending an image.
static window_onunload = (e) =>
{
// Tell other tabs that this tab has closed.
SendImage.send_message({ message: "tab-closed" });
}
static query_tabs()
{
SendImage.send_message({ message: "list-tabs" });
}
// Send an image to another tab. action is either "temp-view", to show the image temporarily,
// or "display", to navigate to it.
static async send_image(media_id, tab_ids, action)
{
// Send everything we know about the image, so the receiver doesn't have to
// do a lookup.
let thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(media_id);
let illust_data = image_data.singleton().get_media_info_sync(media_id);
let user_id = illust_data?.userId;
let user_info = user_id? image_data.singleton().get_user_info_sync(user_id):null;
this.send_message({
message: "send-image",
from: SendImage.tab_id,
to: tab_ids,
media_id: media_id,
action: action, // "temp-view" or "display"
thumbnail_info: thumbnail_info,
illust_data: illust_data,
user_info: user_info,
}, false);
}
static received_message = (e) =>
{
let data = e.data;
// If this message has a target and it's not us, ignore it.
if(data.to && data.to.indexOf(SendImage.tab_id) == -1)
return;
let event = new Event(data.message);
event.message = data;
this.messages.dispatchEvent(event);
// Call any listeners for this message.
if(this.listeners[data.message])
{
for(let func of this.listeners[data.message])
func(data);
}
if(data.message == "tab-info")
{
// Info about a new tab, or a change in visibility.
//
// This may contain thumbnail and illust info. We don't register it here. It
// can be used explicitly when we're displaying a tab thumbnail, but each tab
// might have newer or older image info, and propagating them back and forth
// could be confusing.
if(data.from == SendImage.tab_id)
{
// The other tab has the same ID we do. The only way this normally happens
// is if a tab is duplicated, which will duplicate its sessionStorage with it.
// If this happens, use tab_id_tiebreaker to decide who wins. The tab with
// the higher value will recreate its tab ID. This is set to the time when
// we're loaded, so this will usually cause new tabs to be the one to create
// a new ID.
if(SendImage.tab_id_tiebreaker >= data.tab_id_tiebreaker)
{
console.log("Creating a new tab ID due to ID conflict");
SendImage.tab_id = SendImage.create_tab_id(true /* recreate */ );
}
else
console.log("Tab ID conflict (other tab will create a new ID)");
// Broadcast info. If we recreated our ID then we want to broadcast it on the
// new ID. If we didn't, we still want to broadcast it to replace the info
// the other tab just sent on our ID.
this.broadcast_tab_info();
}
this.known_tabs[data.from] = data;
}
else if(data.message == "tab-closed")
{
delete this.known_tabs[data.from];
}
else if(data.message == "list-tabs")
{
// A new tab is populating its tab list.
this.broadcast_tab_info();
}
else if(data.message == "send-image")
{
// If this message has illust info or thumbnail info, register it.
let thumbnail_info = data.thumbnail_info;
if(thumbnail_info != null)
thumbnail_data.singleton().loaded_thumbnail_info([thumbnail_info], "internal");
let user_info = data.user_info;
if(user_info != null)
image_data.singleton().add_user_data(user_info);
let illust_data = data.illust_data;
if(illust_data != null)
image_data.singleton().add_illust_data(illust_data);
// To finalize, just remove preview and quick-view from the URL to turn the current
// preview into a real navigation. This is slightly different from sending "display"
// with the illust ID, since it handles navigation during quick view.
if(data.action == "finalize")
{
let args = ppixiv.helpers.args.location;
args.hash.delete("virtual");
args.hash.delete("temp-view");
ppixiv.helpers.set_page_url(args, false, "navigation");
return;
}
if(data.action == "cancel")
{
this.hide_preview_image();
return;
}
// Otherwise, we're displaying an image. quick-view displays in quick-view+virtual
// mode, display just navigates to the image normally.
console.assert(data.action == "temp-view" || data.action == "display", data.actionj);
// Show the image.
main_controller.singleton.show_media(data.media_id, {
temp_view: data.action == "temp-view",
source: "temp-view",
// When we first show a preview, add it to history. If we show another image
// or finalize the previewed image while we're showing a preview, replace the
// preview history entry.
add_to_history: !ppixiv.history.virtual,
});
}
else if(data.message == "preview-mouse-movement")
{
// Ignore this message if we're not displaying a quick view image.
if(!ppixiv.history.virtual)
return;
// The mouse moved in the tab that's sending quick view. Broadcast an event
// like pointermove.
let event = new PointerEvent("quickviewpointermove", {
movementX: data.x,
movementY: data.y,
});
window.dispatchEvent(event);
}
}
static broadcast_tab_info = () =>
{
let screen = main_controller.singleton.displayed_screen;
let media_id = screen? screen.displayed_media_id:null;
let thumbnail_info = media_id? thumbnail_data.singleton().get_one_thumbnail_info(media_id):null;
let illust_data = media_id? image_data.singleton().get_media_info_sync(media_id):null;
let user_id = illust_data?.userId;
let user_info = user_id? image_data.singleton().get_user_info_sync(user_id):null;
let our_tab_info = {
message: "tab-info",
tab_id_tiebreaker: SendImage.tab_id_tiebreaker,
visible: !document.hidden,
title: document.title,
window_width: window.innerWidth,
window_height: window.innerHeight,
screen_x: window.screenX,
screen_y: window.screenY,
media_id: media_id,
// Include whatever we know about this image, so if we want to display this in
// another tab, we don't have to look it up again.
thumbnail_info: thumbnail_info,
illust_data: illust_data,
user_info: user_info,
};
this.send_message(our_tab_info);
// Add us to our own known_tabs.
this.known_tabs[SendImage.tab_id] = our_tab_info;
}
static send_message(data, send_to_self)
{
// Include the tab ID in all messages.
data.from = this.tab_id;
this.send_image_channel.postMessage(data);
if(send_to_self)
{
// Make a copy of data, so we don't modify the caller's copy.
data = JSON.parse(JSON.stringify(data));
// Set self to true to let us know that this is our own message.
data.self = true;
this.send_image_channel.dispatchEvent(new MessageEvent("message", { data: data }));
}
}
// If we're currently showing a preview image sent from another tab, back out to
// where we were before.
static hide_preview_image()
{
let was_in_preview = ppixiv.history.virtual;
if(!was_in_preview)
return;
ppixiv.history.back();
}
static send_mouse_movement_to_linked_tabs(x, y)
{
let tab_ids = settings.get("linked_tabs", []);
if(tab_ids.length == 0)
return;
this.pending_movement[0] += x;
this.pending_movement[1] += y;
// Limit the rate we send these, since mice with high report rates can send updates
// fast enough to saturate BroadcastChannel and cause messages to back up. Add up
// movement if we're sending too quickly and batch it into the next message.
if(this.last_movement_message_time != null && Date.now() - this.last_movement_message_time < 10)
return;
this.last_movement_message_time = Date.now();
SendImage.send_message({
message: "preview-mouse-movement",
x: this.pending_movement[0],
y: this.pending_movement[1],
to: tab_ids,
}, false);
this.pending_movement = [0, 0];
}
};
ppixiv.link_tabs_popup = class extends ppixiv.dialog_widget
{
constructor({...options})
{
super({...options, template: \`
\`});
// Close if the container is clicked, but not if something inside the container is clicked.
this.container.addEventListener("click", (e) => {
if(e.target != this.container)
return;
this.visible = false;
});
// Refresh the "unlink all tabs" button on other tabs when the linked tab list changes.
settings.changes.addEventListener("linked_tabs", this.send_link_tab_message, { signal: this.shutdown_signal.signal });
// The other tab will send these messages when the link and unlink buttons
// are clicked.
SendImage.messages.addEventListener("link-this-tab", (e) => {
let message = e.message;
let tab_ids = settings.get("linked_tabs", []);
if(tab_ids.indexOf(message.from) == -1)
tab_ids.push(message.from);
settings.set("linked_tabs", tab_ids);
this.send_link_tab_message();
}, { signal: this.shutdown_signal.signal });
SendImage.messages.addEventListener("unlink-this-tab", (e) => {
let message = e.message;
let tab_ids = settings.get("linked_tabs", []);
let idx = tab_ids.indexOf(message.from);
if(idx != -1)
tab_ids.splice(idx, 1);
settings.set("linked_tabs", tab_ids);
this.send_link_tab_message();
});
this.visible = false;
}
// Send show-link-tab to tell other tabs to display the "link this tab" popup.
// This includes the linked tab list, so they know whether to say "link" or "unlink".
send_link_tab_message = () =>
{
if(!this.visible)
return;
SendImage.send_message({
message: "show-link-tab",
linked_tabs: settings.get("linked_tabs", []),
});
}
visibility_changed()
{
super.visibility_changed();
if(!this.visible)
{
SendImage.send_message({ message: "hide-link-tab" });
return;
}
helpers.interval(this.send_link_tab_message, 1000, this.visibility_abort.signal);
}
}
ppixiv.link_this_tab_popup = class extends ppixiv.dialog_widget
{
constructor({...options})
{
super({...options, template: \`
\`});
this.hide_timer = new helpers.timer(() => { this.visible = false; });
// Show ourself when we see a show-link-tab message and hide if we see a
// hide-link-tab-message.
SendImage.add_message_listener("show-link-tab", (message) => {
this.other_tab_id = message.from;
this.hide_timer.set(2000);
let linked = message.linked_tabs.indexOf(SendImage.tab_id) != -1;
this.container.querySelector(".link-this-tab").hidden = linked;
this.container.querySelector(".unlink-this-tab").hidden = !linked;
this.visible = true;
});
SendImage.add_message_listener("hide-link-tab", (message) => {
this.hide_timer.clear();
this.visible = false;
});
// When "link this tab" is clicked, send a link-this-tab message.
this.container.querySelector(".link-this-tab").addEventListener("click", (e) => {
SendImage.send_message({ message: "link-this-tab", to: [this.other_tab_id] });
// If we're linked to another tab, clear our linked tab list, to try to make
// sure we don't have weird chains of tabs linking each other.
settings.set("linked_tabs", []);
});
this.container.querySelector(".unlink-this-tab").addEventListener("click", (e) => {
SendImage.send_message({ message: "unlink-this-tab", to: [this.other_tab_id] });
});
this.visible = false;
}
visibility_changed()
{
super.visibility_changed();
this.hide_timer.clear();
// Hide if we don't see a show-link-tab message for a few seconds, as a
// safety in case the other tab dies.
if(this.visible)
this.hide_timer.set(2000);
}
}
ppixiv.send_image_popup = class extends ppixiv.dialog_widget
{
constructor({...options})
{
super({...options, template: \`
\`});
// Close if the container is clicked, but not if something inside the container is clicked.
this.container.addEventListener("click", (e) => {
if(e.target != this.container)
return;
this.visible = false;
});
SendImage.add_message_listener("take-image", (message) => {
let tab_id = message.from;
SendImage.send_image(this.media_id, [tab_id], "display");
this.visible = false;
});
this.visible = false;
}
show_for_illust(media_id)
{
this.media_id = media_id;
this.visible = true;
}
visibility_changed()
{
super.visibility_changed();
if(!this.visible)
{
SendImage.send_message({ message: "hide-send-image" });
return;
}
helpers.interval(() => {
// We should always be visible when this is called.
console.assert(this.visible);
SendImage.send_message({ message: "show-send-image" });
}, 1000, this.visibility_abort.signal);
}
}
ppixiv.send_here_popup = class extends ppixiv.dialog_widget
{
constructor({...options})
{
super({...options, template: \`
\`});
this.hide_timer = new helpers.timer(() => { this.visible = false; });
// Show ourself when we see a show-link-tab message and hide if we see a
// hide-link-tab-message.
SendImage.add_message_listener("show-send-image", (message) => {
this.other_tab_id = message.from;
this.hide_timer.set(2000);
this.visible = true;
});
SendImage.add_message_listener("hide-send-image", (message) => {
this.hide_timer.clear();
this.visible = false;
});
this.visible = false;
}
take_image = (e) =>
{
// Send take-image. The sending tab will respond with a send-image message.
SendImage.send_message({ message: "take-image", to: [this.other_tab_id] });
}
visibility_changed()
{
super.visibility_changed();
this.hide_timer.clear();
// Hide if we don't see a show-send-image message for a few seconds, as a
// safety in case the other tab dies.
if(this.visible)
{
window.addEventListener("click", this.take_image, { signal: this.visibility_abort.signal });
this.hide_timer.set(2000);
}
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r132/src/send_image.js
`;
ppixiv.resources["src/main.js"] = `"use strict";
// This handles high-level navigation and controlling the different screens.
ppixiv.main_controller = class
{
// This is called by bootstrap at startup. Just create ourself.
static launch() { new this; }
static get singleton()
{
if(main_controller._singleton == null)
throw "main_controller isn't created";
return main_controller._singleton;
}
constructor()
{
if(main_controller._singleton != null)
throw "main_controller is already created";
main_controller._singleton = this;
this.initial_setup();
}
async initial_setup()
{
try {
// GM_info isn't a property on window in all script managers, so we can't check it
// safely with window.GM_info?.scriptHandler. Instead, try to check it and catch
// the exception if GM_info isn't there for some reason.
if(!ppixiv.native && GM_info?.scriptHandler == "Greasemonkey")
{
console.info("ppixiv doesn't work with GreaseMonkey. GreaseMonkey hasn't been updated in a long time, try TamperMonkey instead.");
return;
}
} catch(e) {
console.error(e);
}
// If we're not active, just see if we need to add our button, and stop without messing
// around with the page more than we need to.
if(!page_manager.singleton().active)
{
console.log("ppixiv is currently disabled");
await helpers.wait_for_content_loaded();
this.setup_disabled_ui();
return;
}
console.log("ppixiv setup");
// Install polyfills. Make sure we only do this if we're active, so we don't
// inject polyfills into Pixiv when we're not active.
install_polyfills();
// Run cleanup_environment. This will try to prevent the underlying page scripts from
// making network requests or creating elements, and apply other irreversible cleanups
// that we don't want to do before we know we're going to proceed.
helpers.cleanup_environment();
this.temporarily_hide_document();
// Wait for DOMContentLoaded to continue.
await helpers.wait_for_content_loaded();
// Continue with full initialization.
await this.setup();
}
// This is where the actual UI starts.
async setup()
{
console.log("ppixiv controller setup");
// Create the page manager.
page_manager.singleton();
// Run any one-time settings migrations.
settings.migrate();
// Migrate the translation database. We don't need to wait for this.
update_translation_storage.run();
// Set up the pointer_listener singleton.
pointer_listener.install_global_handler();
new ppixiv.global_key_listener;
// If we're running natively, set the initial URL.
await local_api.set_initial_url();
// Pixiv scripts that use meta-global-data remove the element from the page after
// it's parsed for some reason. Try to get global info from document, and if it's
// not there, re-fetch the page to get it.
if(!this.load_global_info_from_document(document))
{
if(!await this.load_global_data_async())
return;
}
// Set the .premium class on body if this is a premium account, to display features
// that only work with premium.
helpers.set_class(document.body, "premium", window.global_data.premium);
// These are used to hide UI when running native or not native.
helpers.set_class(document.body, "native", ppixiv.native);
helpers.set_class(document.body, "pixiv", !ppixiv.native);
// These are used to hide buttons that the user has disabled.
helpers.set_class(document.body, "hide-r18", !window.global_data.include_r18);
helpers.set_class(document.body, "hide-r18g", !window.global_data.include_r18g);
// See if the page has preload data. This sometimes contains illust and user info
// that the page will display, which lets us avoid making a separate API call for it.
let preload = document.querySelector("#meta-preload-data");
if(preload != null)
{
preload = JSON.parse(preload.getAttribute("content"));
for(var preload_user_id in preload.user)
image_data.singleton().add_user_data(preload.user[preload_user_id]);
for(var preload_illust_id in preload.illust)
image_data.singleton().add_illust_data(preload.illust[preload_illust_id]);
}
window.addEventListener("click", this.window_onclick_capture);
window.addEventListener("popstate", this.window_onpopstate);
window.addEventListener("keyup", this.redirect_event_to_screen, true);
window.addEventListener("keydown", this.redirect_event_to_screen, true);
window.addEventListener("keypress", this.redirect_event_to_screen, true);
window.addEventListener("keydown", this.onkeydown);
this.current_screen_name = null;
// If the URL hash doesn't start with #ppixiv, the page was loaded with the base Pixiv
// URL, and we're active by default. Add #ppixiv to the URL. If we don't do this, we'll
// still work, but none of the URLs we create will have #ppixiv, so we won't handle navigation
// directly and the page will reload on every click. Do this before we create any of our
// UI, so our links inherit the hash.
if(!ppixiv.native && !helpers.is_ppixiv_url(ppixiv.location))
{
// Don't create a new history state.
let newURL = new URL(ppixiv.location);
newURL.hash = "#ppixiv";
history.replaceState(null, "", newURL.toString());
}
// Don't restore the scroll position.
//
// If we browser back to a search page and we were scrolled ten pages down, scroll
// restoration will try to scroll down to it incrementally, causing us to load all
// data in the search from the top all the way down to where we were. This can cause
// us to spam the server with dozens of requests. This happens on F5 refresh, which
// isn't useful (if you're refreshing a search page, you want to see new results anyway),
// and recommendations pages are different every time anyway.
//
// This won't affect browser back from an image to the enclosing search.
history.scrollRestoration = "manual";
// Remove everything from the page and move it into a dummy document.
var html = document.createElement("document");
helpers.move_children(document.head, html);
helpers.move_children(document.body, html);
// Copy the location to the document copy, so the data source can tell where
// it came from.
html.location = ppixiv.location;
// Now that we've cleared the document, we can unhide it.
document.documentElement.hidden = false;
// Load image resources into blobs.
await this.load_resource_blobs();
// Add the blobs for binary resources as CSS variables.
helpers.add_style("image-styles", \`
body {
--dark-noise: url("\${resources['resources/noise.png']}");
}
\`);
// Add the main stylesheet.
{
let link = document.realCreateElement("link");
link.href = resources['resources/main.scss'];
link.rel = "stylesheet";
document.querySelector("head").appendChild(link);
}
// Create the page from our HTML resource.
let font_link = document.createElement("link");
font_link.href = "https://fonts.googleapis.com/icon?family=Material+Icons";
document.head.appendChild(font_link);
font_link.rel = "stylesheet";
document.body.insertAdjacentHTML("beforeend", resources['resources/main.html']);
helpers.replace_inlines(document.body);
// Create the shared title and page icon.
document.head.appendChild(document.createElement("title"));
var document_icon = document.head.appendChild(document.createElement("link"));
document_icon.setAttribute("rel", "icon");
helpers.add_clicks_to_search_history(document.body);
this.container = document.body;
SendImage.init();
// Create the popup menu handler.
this.context_menu = new main_context_menu({container: document.body});
this.link_tabs_popup = new link_tabs_popup({container: document.body});
this.link_this_tab_popup = new link_this_tab_popup({container: document.body});
this.send_here_popup = new send_here_popup({container: document.body});
this.send_image_popup = new send_image_popup({container: document.body});
// Create the main progress bar.
this.progress_bar = new progress_bar(this.container.querySelector(".loading-progress-bar"));
// Create the screens.
this.screen_search = new screen_search({ contents: this.container.querySelector(".screen-search-container") });
this.screen_illust = new screen_illust({ contents: this.container.querySelector(".screen-illust-container") });
this.screens = {
search: this.screen_search,
illust: this.screen_illust,
};
// Create the data source for this page.
this.set_current_data_source("initialization");
};
window_onpopstate = (e) =>
{
// Set the current data source and state.
this.set_current_data_source(e.navigationCause || "history");
}
async refresh_current_data_source()
{
if(this.data_source == null)
return;
// Create a new data source for the same URL, replacing the previous one.
// This returns the data source, but just call set_current_data_source so
// we load the new one.
console.log("Refreshing data source for", ppixiv.location.toString());
page_manager.singleton().create_data_source_for_url(ppixiv.location, true);
// Screens store their scroll position in args.state.scroll. On refresh, clear it
// so we scroll to the top when we refresh.
let args = helpers.args.location;
delete args.state.scroll;
helpers.set_page_url(args, false, "refresh-data-source", { send_popstate: false });
await this.set_current_data_source("refresh");
}
// Create a data source for the current URL and activate it.
//
// This is called on startup, and in onpopstate where we might be changing data sources.
async set_current_data_source(cause)
{
// Remember what we were displaying before we start changing things.
var old_screen = this.screens[this.current_screen_name];
var old_media_id = old_screen? old_screen.displayed_media_id:null;
// Get the current data source. If we've already created it, this will just return
// the same object and not create a new one.
let data_source = page_manager.singleton().create_data_source_for_url(ppixiv.location);
// If the data source supports_start_page, and a link was clicked on a page that isn't currently
// loaded, create a new data source. If we're on page 5 of bookmarks and the user clicks a link
// for page 1 (the main bookmarks navigation button) or page 10, the current data source can't
// display that since we'd need to load every page in-between to keep pages contiguous, so we
// just create a new data source.
//
// This doesn't work great for jumping to arbitrary pages (we don't handle scrolling to that page
// very well), but it at least makes rewinding to the first page work.
if(data_source == this.data_source && data_source.supports_start_page)
{
let wanted_page = this.data_source.get_start_page(helpers.args.location);
if(!data_source.can_load_page(wanted_page))
{
// This works the same as refresh_current_data_source above.
console.log("Resetting data source because it can't load the requested page", wanted_page);
data_source = page_manager.singleton().create_data_source_for_url(ppixiv.location, true);
}
}
// Figure out which screen to display.
var new_screen_name;
let args = helpers.args.location;
if(!args.hash.has("view"))
new_screen_name = data_source.default_screen;
else
new_screen_name = args.hash.get("view");
// If the data source is changing, set it up.
if(this.data_source != data_source)
{
console.log("New data source. Screen:", new_screen_name, "Cause:", cause);
if(this.data_source != null)
{
// Shut down the old data source.
this.data_source.shutdown();
// If the old data source was transient, discard it.
if(this.data_source.transient)
page_manager.singleton().discard_data_source(this.data_source);
}
// If we were showing a message for the old data source, it might be persistent,
// so clear it.
message_widget.singleton.hide();
this.data_source = data_source;
this.show_data_source_specific_elements();
this.context_menu.set_data_source(data_source);
if(this.data_source != null)
this.data_source.startup();
}
else
console.log("Same data source. Screen:", new_screen_name, "Cause:", cause);
// Update the media ID with the current manga page, if any.
let media_id = data_source.get_current_media_id();
let id = helpers.parse_media_id(media_id);
id.page = args.hash.has("page")? parseInt(args.hash.get("page"))-1:0;
media_id = helpers.encode_media_id(id);
// If we're on search, we don't care what image is current. Clear media_id so we
// tell context_menu that we're not viewing anything, so it disables bookmarking.
if(new_screen_name == "search")
media_id = null;
// Mark the current screen. Other code can watch for this to tell which view is
// active.
document.body.dataset.currentView = new_screen_name;
let new_screen = this.screens[new_screen_name];
this.context_menu.set_media_id(media_id);
this.current_screen_name = new_screen_name;
// If we're changing between screens, update the active screen.
let screen_changing = new_screen != old_screen;
// Dismiss any message when toggling between screens.
if(screen_changing)
message_widget.singleton.hide();
// Make sure we deactivate the old screen before activating the new one.
if(old_screen != null && old_screen != new_screen)
await old_screen.set_active(false, { });
if(new_screen != null)
{
// Restore state from history if this is an initial load (which may be
// restoring a tab), for browser forward/back, or if we're exiting from
// quick view (which is like browser back). This causes the pan/zoom state
// to be restored.
let restore_history = cause == "initialization" || cause == "history" || cause == "leaving-virtual";
await new_screen.set_active(true, {
data_source: data_source,
media_id: media_id,
// Let the screen know what ID we were previously viewing, if any.
old_media_id: old_media_id,
restore_history: restore_history,
});
}
}
show_data_source_specific_elements()
{
// Show UI elements with this data source in their data-datasource attribute.
var data_source_name = this.data_source.name;
for(var node of this.container.querySelectorAll(".data-source-specific[data-datasource]"))
{
var data_sources = node.dataset.datasource.split(" ");
var show_element = data_sources.indexOf(data_source_name) != -1;
node.hidden = !show_element;
}
}
// Show an illustration by ID.
//
// This actually just sets the history URL. We'll do the rest of the work in popstate.
show_media(media_id, {add_to_history=false, screen="illust", temp_view=false, source=""}={})
{
console.assert(media_id != null, "Invalid illust_id", media_id);
let args = helpers.args.location;
// Check if this is a local ID.
if(helpers.is_media_id_local(media_id))
{
// If we're told to show a folder: ID, always go to the search page, not the illust page.
if(helpers.parse_media_id(media_id).type == "folder")
screen = "search";
}
// Update the URL to display this media_id. This stays on the same data source,
// so displaying an illust won't cause a search to be made in the background or
// have other side-effects.
this._set_active_screen_in_url(args, screen);
this.data_source.set_current_media_id(media_id, args);
// Remove any leftover page from the current illust. We'll load the default.
let [illust_id, page] = helpers.media_id_to_illust_id_and_page(media_id);
if(page == null)
args.hash.delete("page");
else
args.hash.set("page", page + 1);
if(temp_view)
{
args.hash.set("virtual", "1");
args.hash.set("temp-view", "1");
}
else
{
args.hash.delete("virtual");
args.hash.delete("temp-view");
}
helpers.set_page_url(args, add_to_history, "navigation");
}
// Return the displayed screen instance.
get displayed_screen()
{
for(let screen_name in this.screens)
{
var screen = this.screens[screen_name];
if(screen.active)
return screen;
}
return null;
}
_set_active_screen_in_url(args, screen)
{
// If this is the default, just remove it.
if(screen == this.data_source.default_screen)
args.hash.delete("view");
else
args.hash.set("view", screen);
// If we're going to the search screen, remove the page and illust ID.
if(screen == "search")
{
args.hash.delete("page");
args.hash.delete("illust_id");
}
// If we're going somewhere other than illust, remove zoom state, so
// it's not still around the next time we view an image.
if(screen != "illust")
delete args.state.zoom;
}
get navigate_out_enabled()
{
if(this.current_screen_name != "illust" || this.data_source == null)
return false;
let media_id = this.data_source.get_current_media_id();
if(media_id == null)
return false;
let info = thumbnail_data.singleton().get_illust_data_sync(media_id);
if(info == null)
return false;
return info.pageCount > 1;
}
navigate_out()
{
if(!this.navigate_out_enabled)
return;
let media_id = this.data_source.get_current_media_id();
if(media_id == null)
return;
let [illust_id, illust_page] = helpers.media_id_to_illust_id_and_page(media_id);
let args = new helpers.args(\`/artworks/\${illust_id}#ppixiv?manga=1\`);
helpers.set_page_url(args, true /* add_to_history */, "out");
}
// This captures clicks at the window level, allowing us to override them.
//
// When the user left clicks on a link that also goes into one of our screens,
// rather than loading a new page, we just set up a new data source, so we
// don't have to do a full navigation.
//
// This only affects left clicks (middle clicks into a new tab still behave
// normally).
window_onclick_capture = (e) =>
{
// Only intercept regular left clicks.
if(e.button != 0 || e.metaKey || e.ctrlKey || e.altKey)
return;
if(!(e.target instanceof Element))
return;
// We're taking the place of the default behavior. If somebody called preventDefault(),
// stop.
if(e.defaultPrevented)
return;
// Look up from the target for a link.
var a = e.target.closest("A");
if(a == null || !a.hasAttribute("href"))
return;
// If this isn't a #ppixiv URL, let it run normally.
let url = new unsafeWindow.URL(a.href, document.href);
if(!helpers.is_ppixiv_url(url))
return;
// Stop all handling for this link.
e.preventDefault();
e.stopImmediatePropagation();
// If this is a link to an image (usually /artworks/#), navigate to the image directly.
// This way, we actually use the URL for the illustration on this data source instead of
// switching to /artworks. This also applies to local image IDs, but not folders.
url = helpers.get_url_without_language(url);
let illust = this.get_illust_at_element(a);
if(illust?.media_id)
{
let media_id = illust.media_id;
let args = new helpers.args(a.href);
let screen = args.hash.has("view")? args.hash.get("view"):"illust";
this.show_media(media_id, {
screen: screen,
add_to_history: true
});
return;
}
// Navigate to the URL in-page.
helpers.set_page_url(url, true /* add to history */, "navigation");
}
async load_global_data_async()
{
console.assert(!ppixiv.native);
// Doing this sync works better, because it
console.log("Reloading page to get init data");
// /local is used as a placeholder path for the local API, and it's a 404
// on the actual page. It doesn't have global data, so load some other arbitrary
// page to get it.
let url = document.location;
if(url.pathname.startsWith('/local'))
url = new URL("/discovery", url);
// Some Pixiv pages try to force cache expiry. We really don't want that to happen
// here, since we just want to grab the page we're on quickly. Setting cache: force_cache
// tells Chrome to give us the cached page even if it's expired.
let result = await helpers.load_data_in_iframe(url.toString(), {
cache: "force-cache",
});
console.log("Finished loading init data");
if(this.load_global_info_from_document(result))
return true;
// The user is probably not logged in. If this happens on this code path, we
// can't restore the page.
console.log("Couldn't find context data. Are we logged in?");
this.show_logout_message(true);
return false;
}
// Load Pixiv's global info from doc. This can be the document, or a copy of the
// document that we fetched separately. Return true on success.
load_global_info_from_document(doc)
{
// When running locally, just load stub data, since this isn't used.
if(ppixiv.native)
{
this.init_global_data("no token", "no id", true, [], 2);
return true;
}
// Stop if we already have this.
if(window.global_data)
return true;
// This format is used on at least /new_illust.php.
let global_data = doc.querySelector("#meta-global-data");
if(global_data != null)
global_data = JSON.parse(global_data.getAttribute("content"));
// This is the global "pixiv" object, which is used on older pages.
let pixiv = helpers.get_pixiv_data(doc);
// Hack: don't use this object if we're on /history.php. It has both of these, and
// this object doesn't actually have all info, but its presence will prevent us from
// falling back and loading meta-global-data if needed.
if(document.location.pathname == "/history.php")
pixiv = null;
// Discard any of these that have no login info.
if(global_data && global_data.userData == null)
global_data = null;
if(pixiv && (pixiv.user == null || pixiv.user.id == null))
pixiv = null;
if(global_data == null && pixiv == null)
return false;
if(global_data != null)
{
this.init_global_data(global_data.token, global_data.userData.id, global_data.userData.premium,
global_data.mute, global_data.userData.xRestrict);
}
else
{
this.init_global_data(pixiv.context.token, pixiv.user.id, pixiv.user.premium,
pixiv.user.mutes, pixiv.user.explicit);
}
return true;
}
init_global_data(csrf_token, user_id, premium, mutes, content_mode)
{
var muted_tags = [];
var muted_user_ids = [];
for(var mute of mutes)
{
if(mute.type == 0)
muted_tags.push(mute.value);
else if(mute.type == 1)
muted_user_ids.push(mute.value);
}
muting.singleton.pixiv_muted_tags = muted_tags;
muting.singleton.pixiv_muted_user_ids = muted_user_ids;
window.global_data = {
// Store the token for XHR requests.
csrf_token: csrf_token,
user_id: user_id,
include_r18: content_mode >= 1,
include_r18g: content_mode >= 2,
premium: premium,
};
};
// Redirect keyboard events that didn't go into the active screen.
redirect_event_to_screen = (e) =>
{
let screen = this.displayed_screen;
if(screen == null)
return;
// If a popup is open, leave inputs alone.
if(document.body.dataset.popupOpen)
return;
// If the keyboard input didn't go to an element inside the screen, redirect
// it to the screen's container.
var target = e.target;
// If the event is going to an element inside the screen already, just let it continue.
if(helpers.is_above(screen.container, e.target))
return;
// Clone the event and redispatch it to the screen's container.
var e2 = new e.constructor(e.type, e);
if(!screen.container.dispatchEvent(e2))
{
e.preventDefault();
e.stopImmediatePropagation();
return;
}
}
onkeydown = (e) =>
{
// Ignore keypresses if we haven't set up the screen yet.
let screen = this.displayed_screen;
if(screen == null)
return;
// If a popup is open, leave inputs alone and don't process hotkeys.
if(document.body.dataset.popupOpen)
return;
if(e.key == "Escape")
{
e.preventDefault();
e.stopPropagation();
this.navigate_out();
return;
}
// Let the screen handle the input.
screen.handle_onkeydown(e);
}
// Return the illust_id and page or user_id of the image under element. This can
// be an image in the search screen, or a page in the manga screen.
//
// If element is an illustration and also has the user ID attached, both the user ID
// and illust ID will be returned.
get_illust_at_element(element)
{
let result = { };
if(element == null)
return result;
// Illustration search results have both the media ID and the user ID on it.
let media_element = element.closest("[data-media-id]");
if(media_element)
result.media_id = media_element.dataset.mediaId;
let user_element = element.closest("[data-user-id]");
if(user_element)
result.user_id = user_element.dataset.userId;
return result;
}
// Load binary resources into blobs, so we don't copy images into every
// place they're used.
async load_resource_blobs()
{
for(let [name, dataURL] of Object.entries(ppixiv.resources))
{
if(!dataURL.startsWith || !dataURL.startsWith("data:"))
continue;
let result = await fetch(dataURL);
let blob = await result.blob();
let blobURL = URL.createObjectURL(blob);
ppixiv.resources[name] = blobURL;
}
}
show_logout_message(force)
{
// Unless forced, don't show the message if we've already shown it recently.
// A session might last for weeks, so we don't want to force it to only be shown
// once, but we don't want to show it repeatedly.
let last_shown = window.sessionStorage.showed_logout_message || 0;
let time_since_shown = Date.now() - last_shown;
let hours_since_shown = time_since_shown / (60*60*1000);
if(!force && hours_since_shown < 6)
return;
window.sessionStorage.showed_logout_message = Date.now();
alert("Please log in to use ppixiv.");
}
temporarily_hide_document()
{
if(document.documentElement != null)
{
document.documentElement.hidden = true;
return;
}
// At this point, none of the document has loaded, and document.body and
// document.documentElement don't exist yet, so we can't hide it. However,
// we want to hide the document as soon as it's added, so we don't flash
// the original page before we have a chance to replace it. Use a mutationObserver
// to detect the document being created.
var observer = new MutationObserver((mutation_list) => {
if(document.documentElement == null)
return;
observer.disconnect();
document.documentElement.hidden = true;
});
observer.observe(document, { attributes: false, childList: true, subtree: true });
};
// When we're disabled, but available on the current page, add the button to enable us.
async setup_disabled_ui(logged_out=false)
{
// Wait for DOMContentLoaded for body.
await helpers.wait_for_content_loaded();
// On most pages, we show our button in the top corner to enable us on that page. Clicking
// it on a search page will switch to us on the same search.
var disabled_ui = helpers.create_node(resources['resources/disabled.html']);
helpers.replace_inlines(disabled_ui);
this.refresh_disabled_ui(disabled_ui);
document.body.appendChild(disabled_ui);
// Newer Pixiv pages update the URL without navigating, so refresh our button with the current
// URL. We should be able to do this in popstate, but that API has a design error: it isn't
// called on pushState, only on user navigation, so there's no way to tell when the URL changes.
// This results in the URL changing when it's clicked, but that's better than going to the wrong
// page.
disabled_ui.addEventListener("focus", (e) => { this.refresh_disabled_ui(disabled_ui); }, true);
window.addEventListener("popstate", (e) => { this.refresh_disabled_ui(disabled_ui); }, true);
if(page_manager.singleton().available_for_url(ppixiv.location))
{
// Remember that we're disabled in this tab. This way, clicking the "return
// to Pixiv" button will remember that we're disabled. We do this on page load
// rather than when the button is clicked so this works when middle-clicking
// the button to open a regular Pixiv page in a tab.
//
// Only do this if we're available and disabled, which means the user disabled us.
// If we wouldn't be available on this page at all, don't store it.
page_manager.singleton().store_ppixiv_disabled(true);
}
// If we're showing this and we know we're logged out, show a message on click.
// This doesn't work if we would be inactive anyway, since we don't know whether
// we're logged in, so the user may need to click the button twice before actually
// seeing this message.
if(logged_out)
{
disabled_ui.querySelector("a").addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
this.show_logout_message(true);
});
}
};
refresh_disabled_ui(disabled_ui)
{
// If we're on a page that we don't support, like the top page, rewrite the link to switch to
// a page we do support. Otherwise, replace the hash with #ppixiv.
console.log(ppixiv.location.toString());
if(page_manager.singleton().available_for_url(ppixiv.location))
{
let url = ppixiv.location;
url.hash = "#ppixiv";
disabled_ui.querySelector("a").href = url;
}
else
disabled_ui.querySelector("a").href = "/ranking.php?mode=daily#ppixiv";
}
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r132/src/main.js
`;
// Note that this file doesn't use strict, because JS language developers remove
// useful features without a second thought. "with" may not be used often, but
// it's an important part of the language.
(() => {
// If we're in a release build, we're inside
// (function () {
// with(this)
// {
// ...
// }
// }.exec({});
//
// The empty {} object is our environment. It can be assigned to as "this" at the
// top level of scripts, and it's included in scope using with(this) so it's searched
// as a global scope.
//
// If we're in a debug build, this script runs standalone, and we set up the environment
// here.
// Our source files are stored as text, so we can attach sourceURL to them to give them
// useful filenames. "this" is set to the ppixiv context, and we load them out here so
// we don't have many locals being exposed as globals during the eval. We also need to
// do this out here in order ot use with.
let _load_source_file = function(__pixiv, __source) {
const ppixiv = __pixiv;
with(ppixiv)
{
return eval(__source);
}
};
new class
{
constructor(env)
{
// If this is an iframe, don't do anything.
if(window.top != window.self)
return;
// Don't activate for things like sketch.pixiv.net.
if(window.location.hostname != "www.pixiv.net")
return;
// Make sure that we're not loaded more than once. This can happen if we're installed in
// multiple script managers, or if the release and debug versions are enabled simultaneously.
if(unsafeWindow.loaded_ppixiv)
{
console.error("ppixiv has been loaded twice. Is it loaded in multiple script managers?");
return;
}
unsafeWindow.loaded_ppixiv = true;
console.log("ppixiv bootstrap");
let setup = env.resources["output/setup.js"];
let source_list = setup.source_files;
unsafeWindow.ppixiv = env;
// Load each source file.
for(let path of source_list)
{
let source = env.resources[path];
if(!source)
{
console.error("Source file missing:", path);
continue;
}
_load_source_file(env, source);
}
// Load the stylesheet into a URL. This is just so we behave the same
// as bootstrap_native.
for(let [name, data] of Object.entries(env.resources))
{
if(!name.endsWith(".scss"))
continue;
let blob = new Blob([data]);
let blobURL = URL.createObjectURL(blob);
env.resources[name] = blobURL;
}
// Create the main controller.
env.main_controller.launch();
}
}(this);
})();
}
}).call({});