0)
console.log("Removing duplicate illustration IDs:", ids_to_remove.join(", "));
illust_ids = illust_ids.slice();
for(let new_id of ids_to_remove)
{
let idx = illust_ids.indexOf(new_id);
illust_ids.splice(idx, 1);
}
// If there's nothing on this page, don't add it, so this doesn't increase
// get_highest_loaded_page().
// FIXME: If we removed everything, the data source will appear to have reached the last
// page and we won't load any more pages, since thumbnail_view assumes that a page not
// returning any data means we're at the end.
if(illust_ids.length == 0)
return;
this.illust_ids_by_page.set(page, illust_ids);
};
// Return the page number illust_id is on, or null if we don't know.
get_page_for_illust(illust_id)
{
for(let [page, ids] of this.illust_ids_by_page)
{
if(ids.indexOf(illust_id) != -1)
return page;
}
return null;
};
// Return the next or previous illustration. If we don't have that page, return null.
//
// This only returns illustrations, skipping over any special entries like user:12345.
get_neighboring_illust_id(illust_id, next)
{
for(let i = 0; i < 100; ++i) // sanity limit
{
illust_id = this._get_neighboring_illust_id_internal(illust_id, next);
if(illust_id == null)
return null;
// If it's not an illustration, keep looking.
if(helpers.parse_id(illust_id).type == "illust")
return illust_id;
}
return null;
}
// The actual logic for get_neighboring_illust_id, except for skipping entries.
_get_neighboring_illust_id_internal(illust_id, next)
{
let page = this.get_page_for_illust(illust_id);
if(page == null)
return null;
let ids = this.illust_ids_by_page.get(page);
let idx = ids.indexOf(illust_id);
let new_idx = idx + (next? +1:-1);
if(new_idx < 0)
{
// Return the last illustration on the previous page, or null if that page isn't loaded.
let prev_page_no = page - 1;
let prev_page_illust_ids = this.illust_ids_by_page.get(prev_page_no);
if(prev_page_illust_ids == null)
return null;
return prev_page_illust_ids[prev_page_illust_ids.length-1];
}
else if(new_idx >= ids.length)
{
// Return the first illustration on the next page, or null if that page isn't loaded.
let next_page_no = page + 1;
let next_page_illust_ids = this.illust_ids_by_page.get(next_page_no);
if(next_page_illust_ids == null)
return null;
return next_page_illust_ids[0];
}
else
{
return ids[new_idx];
}
};
// Return the page we need to load to get the next or previous illustration. This only
// makes sense if get_neighboring_illust returns null.
get_page_for_neighboring_illust(illust_id, next)
{
let page = this.get_page_for_illust(illust_id);
if(page == null)
return null;
let ids = this.illust_ids_by_page.get(page);
let idx = ids.indexOf(illust_id);
let new_idx = idx + (next? +1:-1);
if(new_idx >= 0 && new_idx < ids.length)
return page;
page += next? +1:-1;
return page;
};
// Return the first ID, or null if we don't have any.
get_first_id()
{
if(this.illust_ids_by_page.size == 0)
return null;
let keys = this.illust_ids_by_page.keys();
let page = keys.next().value;
return this.illust_ids_by_page.get(page)[0];
}
// Return true if the given page is loaded.
is_page_loaded(page)
{
return this.illust_ids_by_page.has(page);
}
};
// A data source asynchronously loads illust_ids to show. The callback will be called
// with:
// {
// 'illust': {
// illust_id1: illust_data1,
// illust_id2: illust_data2,
// ...
// },
// illust_ids: [illust_id1, illust_id2, ...]
// next: function,
// }
//
// Some sources can retrieve user data, some can retrieve only illustration data, and
// some can't retrieve anything but IDs.
//
// The callback will always be called asynchronously, and data_source.callback can be set
// after creation.
//
// If "next" is included, it's a function that can be called to create a new data source
// to load the next page of data. If there are no more pages, next will be null.
// A data source handles a particular source of images, depending on what page we're
// on:
//
// - Retrieves batches of image IDs to display, eg. a single page of bookmark results
// - Load another page of results with load_more()
// - Updates the page URL to reflect the current image
//
// Not all data sources have multiple pages. For example, when we're viewing a regular
// illustration page, we get all of the author's other illust IDs at once, so we just
// load all of them as a single page.
class data_source
{
constructor(url)
{
this.url = new URL(url);
this.id_list = new illust_id_list();
this.update_callbacks = [];
this.loading_pages = {};
this.first_empty_page = -1;
this.update_callbacks = [];
// If this data source supports a start page, store the page we started on.
// This isn't increased as we load more pages, but if we load earlier results
// because the user clicks "load previous results", we'll reduce it.
if(this.supports_start_page)
{
let args = new helpers.args(url);
this.initial_page = this.get_start_page(args);
console.log("Starting at page", this.initial_page);
}
else
this.initial_page = 1;
};
// If a data source returns a name, we'll display any .data-source-specific elements in
// the thumbnail view with that name.
get name() { return null; }
// Most data sources are for illustrations. This is set to "users" for the followed view.
get search_mode() { return "illusts"; }
// Return a canonical URL for this data source. If the canonical URL is the same,
// the same instance of the data source should be used.
//
// A single data source is used eg. for a particular search and search flags. If
// flags are changed, such as changing filters, a new data source instance is created.
// However, some parts of the URL don't cause a new data source to be used. Return
// a URL with all unrelated parts removed, and with query and hash parameters sorted
// alphabetically.
static get_canonical_url(url)
{
// Make a copy of the URL.
var url = new URL(url);
url = this.remove_ignored_url_parts(url);
// Sort query parameters. We don't use multiple parameters with the same key.
url.search = helpers.sort_query_parameters(url.searchParams).toString();
let args = new helpers.args(url);
// Sort hash parameters.
args.hash = helpers.sort_query_parameters(args.hash);
return args.url.toString();
}
// This is overridden by subclasses to remove parts of the URL that don't affect
// which data source instance is used.
static remove_ignored_url_parts(url)
{
// If p=1 is in the query, it's the page number, which doesn't affect the data source.
url.searchParams.delete("p");
let args = new helpers.args(url);
// The manga page doesn't affect the data source.
args.hash.delete("page");
// #view=thumbs controls which view is active.
args.hash.delete("view");
// illust_id in the hash is always just telling us which image within the current
// data source to view. data_source_current_illust is different and is handled in
// the subclass.
args.hash.delete("illust_id");
// These are for quick view and don't affect the data source.
args.hash.delete("virtual");
args.hash.delete("quick-view");
// This is for overriding muting.
args.hash.delete("view-muted");
return args.url;
}
// startup() is called when the data source becomes active, and shutdown is called when
// it's done. This can be used to add and remove event handlers on the UI.
startup()
{
this.active = true;
}
shutdown()
{
this.active = false;
}
// Load the given page, or the page of the current history state if page is null.
// Call callback when the load finishes.
//
// If we synchronously know that the page doesn't exist, return false and don't
// call callback. Otherwise, return true.
load_page(page, { cause }={})
{
var result = this.loading_pages[page];
if(result == null)
{
var result = this._load_page_async(page, cause);
this.loading_pages[page] = result;
result.finally(() => {
// console.log("finished loading page", page);
delete this.loading_pages[page];
});
}
return result;
}
// Return true if the given page is either loaded, or currently being loaded by a call to load_page.
is_page_loaded_or_loading(page)
{
if(this.id_list.is_page_loaded(page))
return true;
if(this.loading_pages[page])
return true;
return false;
}
// Return true if any page is currently loading.
get any_page_loading()
{
for(let page in this.loading_pages)
if(this.loading_pages[page])
return true;
return false;
}
async _load_page_async(page, cause)
{
// Check if we're trying to load backwards too far.
if(page < 1)
{
console.info("No pages before page 1");
return false;
}
// If we know there's no data on this page (eg. we loaded an earlier page before and it
// was empty), don't try to load this one. This prevents us from spamming empty page
// requests.
if(this.first_empty_page != -1 && page >= this.first_empty_page)
return false;
// If the page is already loaded, stop.
if(this.id_list.is_page_loaded(page))
return true;
// Check if this is past the end.
if(!this.load_page_available(page))
return false;
console.log("Load page", page, "for:", cause);
// Before starting, await at least once so we get pushed to the event loop. This
// guarantees that load_page has a chance to store us in this.loading_pages before
// we do anything that might have side-effects of starting another load.
await null;
// Start the actual load.
var result = await this.load_page_internal(page);
// Reduce the start page, which will update the "load more results" button if any. It's important
// to do this after the await above. If we do it before, it'll update the button before we load
// and cause the button to update before the thumbs. screen_search.refresh_images won't be able
// to optimize that and it'll cause uglier refreshes.
if(this.supports_start_page && page < this.initial_page)
this.initial_page = page;
// If there were no results, then we've loaded the last page. Don't try to load
// any pages beyond this.
if(!this.id_list.illust_ids_by_page.has(page))
{
console.log("No data on page", page);
if(this.first_empty_page == -1 || page < this.first_empty_page)
this.first_empty_page = page;
};
return true;
}
// Return the illust_id to display by default.
//
// This should only be called after the initial data is loaded.
get_current_illust_id()
{
// If we have an explicit illust_id in the hash, use it. Note that some pages (in
// particular illustration pages) put this in the query, which is handled in the particular
// data source.
let args = helpers.args.location;
if(args.hash.has("illust_id"))
return args.hash.get("illust_id");
return this.id_list.get_first_id();
};
// Return the page title to use.
get page_title()
{
return "Pixiv";
}
// This is implemented by the subclass.
async load_page_internal(page)
{
throw "Not implemented";
}
// Return true if page is an available page (not past the end).
//
// We'll always stop if we read a page and it's empty. This allows the extra
// last request to be avoided if we know the last page earlier.
load_page_available(page)
{
return true;
}
// This is called when the currently displayed illust_id changes. The illust_id should
// always have been loaded by this data source, so it should be in id_list. The data
// source should update the history state to reflect the current state.
set_current_illust_id(illust_id, args)
{
if(this.supports_start_page)
{
// Store the page the illustration is on in the hash, so if the page is reloaded while
// we're showing an illustration, we'll start on that page. If we don't do this and
// the user clicks something that came from page 6 while the top of the search results
// were on page 5, we'll start the search at page 5 if the page is reloaded and not find
// the image, which is confusing.
var original_page = this.id_list.get_page_for_illust(illust_id);
if(original_page != null)
this.set_start_page(args, original_page);
}
// By default, put the illust_id in the hash.
args.hash.set("illust_id", illust_id);
}
// Return the estimated number of items per page.
get estimated_items_per_page()
{
// Most newer Pixiv pages show a grid of 6x8 images. Try to match it, so page numbers
// line up.
return 48;
};
// Return the screen that should be displayed by default, if no "view" field is in the URL.
get default_screen()
{
return "search";
}
// If we're viewing a page specific to a user (an illustration or artist page), return
// the user ID we're viewing. This can change when refreshing the UI.
get viewing_user_id()
{
return null;
};
// If a data source is transient, it'll be discarded when the user navigates away instead of
// reused.
get transient() { return false; }
// Some data sources can restart the search at a page.
get supports_start_page() { return false; }
// Store the current page in the URL.
//
// This is only used if supports_start_page is true.
set_start_page(args, page)
{
args.query.set("p", page);
}
get_start_page(args)
{
let page = args.query.get("p") || "1";
return parseInt(page) || 1;
}
// Add or remove an update listener. These are called when the data source has new data,
// or wants a UI refresh to happen.
add_update_listener(callback)
{
this.update_callbacks.push(callback);
}
remove_update_listener(callback)
{
var idx = this.update_callbacks.indexOf(callback);
if(idx != -1)
this.update_callbacks.splice(idx);
}
// Register a page of data.
add_page(page, illust_ids)
{
this.id_list.add_page(page, illust_ids);
// Call update listeners asynchronously to let them know we have more data.
helpers.yield(() => {
this.call_update_listeners();
});
}
call_update_listeners()
{
var callbacks = this.update_callbacks.slice();
for(var callback of callbacks)
callback();
}
// Refresh parts of the UI that are specific to this data source. This is only called
// when first activating a data source, to update things like input fields that shouldn't
// be overwritten on each refresh.
initial_refresh_thumbnail_ui(container, view) { }
// Each data source can have a different UI in the thumbnail view. container is
// the thumbnail-ui-box container to refresh.
refresh_thumbnail_ui(container, view) { }
// A helper for setting up UI links. Find the link with the given data-type,
// set all {key: value} entries as query parameters, and remove any query parameters
// where value is null. Set .selected if the resulting URL matches the current one.
//
// If default_values is present, it tells us the default key that will be used if
// a key isn't present. For example, search.php?s_mode=s_tag is the same as omitting
// s_mode. We prefer to omit it rather than clutter the URL with defaults, but we
// need to know this to figure out whether an item is selected or not.
//
// If a key begins with #, it's placed in the hash rather than the query.
set_item(container, type, fields, default_values)
{
var link = container.querySelector("[data-type='" + type + "']");
if(link == null)
{
console.warn("Couldn't find button with selector", type);
return;
}
// This button is selected if all of the keys it sets are present in the URL.
var button_is_selected = true;
// Adjust the URL for this button.
let url = new URL(this.url);
// Don't include the page number in search buttons, so clicking a filter goes
// back to page 1.
url.searchParams.delete("p");
let args = new helpers.args(url);
for(var key of Object.keys(fields))
{
var original_key = key;
var value = fields[key];
// If key begins with "#", it means it goes in the hash.
var hash = key.startsWith("#");
if(hash)
key = key.substr(1);
let params = hash? args.hash:args.query;
// The value we're setting in the URL:
var this_value = value;
if(this_value == null && default_values != null)
this_value = default_values[original_key];
// The value currently in the URL:
var selected_value = params.get(key);
if(selected_value == null && default_values != null)
selected_value = default_values[original_key];
// If the URL didn't have the key we're setting, then it isn't selected.
if(this_value != selected_value)
button_is_selected = false;
// If the value we're setting is the default, delete it instead.
if(default_values != null && this_value == default_values[original_key])
value = null;
if(value != null)
params.set(key, value);
else
params.delete(key);
}
url = args.url;
helpers.set_class(link, "selected", button_is_selected);
link.href = url.toString();
};
// Like set_item for query and hash parameters, this sets parameters in the URL.
//
// Pixiv used to have clean, consistent URLs with page parameters in the query where
// they belong, but recently they've started encoding them in an ad hoc way into the
// path. For example, what used to look like "/users/12345?type=illust" is now
// "/users/12345/illustrations", so they can't be accessed in a generic way.
//
// index is the index into the path to replace. In "/users/12345/abcd", "users" is
// 0 and "abcd" is 2. If the index doesn't exist, the path will be extended, so
// replacing index 2 in "/users/12345" will become "/users/12345/abcd". This only
// makes sense when adding a single entry.
//
// Pixiv URLs can optionally have the language prefixed (which doesn't make sense).
// This is handled automatically by get_path_part and set_path_part, and index should
// always be for URLs without the language.
set_path_item(container, type, index, value)
{
let link = container.querySelector("[data-type='" + type + "']");
if(link == null)
{
console.warn("Couldn't find button with selector", type);
return;
}
// Adjust the URL for this button.
let url = new URL(this.url);
// Don't include the page number in search buttons, so clicking a filter goes
// back to page 1.
url.searchParams.delete("p");
// This button is selected if the given value was already set.
let button_is_selected = helpers.get_path_part(url, index) == value;
// Replace the path part.
url = helpers.set_path_part(url, index, value);
helpers.set_class(link, "selected", button_is_selected);
link.href = url.toString();
};
// Highlight search menu popups if any entry other than the default in them is
// selected.
//
// selector_list is a list of selectors for each menu item. If any of them are
// selected and don't have the data-default attribute, set .active on the popup.
// Search filters
// Set the active class on all top-level dropdowns which have something other than
// the default selected.
set_active_popup_highlight(container, selector_list)
{
for(var popup of selector_list)
{
var box = container.querySelector(popup);
var selected_item = box.querySelector(".selected");
if(selected_item == null)
{
// There's no selected item. If there's no default item then this is normal, but if
// there's a default item, it should have been selected by default, so this is probably
// a bug.
var default_entry_exists = box.querySelector("[data-default]") != null;
if(default_entry_exists)
console.warn("Popup", popup, "has no selection");
continue;
}
var selected_default = selected_item.dataset["default"];
helpers.set_class(box, "active", !selected_default);
// Find the dropdown menu button.
let menu_button = box.querySelector(".menu-button");
if(menu_button == null)
{
console.warn("Couldn't find menu button for " + box);
continue;
}
// Store the original text, so we can restore it when the default is selected.
if(menu_button.dataset.originalText == null)
menu_button.dataset.originalText = menu_button.innerText;
// If an option is selected, replace the menu button text with the selection's label.
if(selected_default)
menu_button.innerText = menu_button.dataset.originalText;
else
{
// The short label is used to try to keep these labels from causing the menu buttons to
// overflow the container, and for labels like "2 years ago" where the menu text doesn't
// make sense.
let label = selected_item.dataset.shortLabel;
menu_button.innerText = label? label:selected_item.innerText;
}
}
}
// Return true of the thumbnail view should show bookmark icons for this source.
get show_bookmark_icons()
{
return true;
}
// URLs added to links will be included in the links at the top of the page when viewing an artist.
add_extra_links(links)
{
}
};
// Load a list of illust IDs, and allow retriving them by page.
function paginate_illust_ids(illust_ids, items_per_page)
{
// Paginate the big list of results.
var pages = [];
var page = null;
for(var illust_id of illust_ids)
{
if(page == null)
{
page = [];
pages.push(page);
}
page.push(illust_id);
if(page.length == items_per_page)
page = null;
}
return pages;
}
// This extends data_source with local pagination.
//
// A few API calls just return all results as a big list of IDs. We can handle loading
// them all at once, but it results in a very long scroll box, which makes scrolling
// awkward. This artificially paginates the results.
class data_source_fake_pagination extends data_source
{
async load_page_internal(page)
{
if(this.pages == null)
{
var illust_ids = await this.load_all_results();
this.pages = paginate_illust_ids(illust_ids, this.estimated_items_per_page);
}
// Register this page.
var illust_ids = this.pages[page-1] || [];
this.add_page(page, illust_ids);
}
// Implemented by the subclass. Load all results, and return the resulting IDs.
async load_all_results()
{
throw "Not implemented";
}
}
// /discovery - Recommended Works
ppixiv.data_sources.discovery = class extends data_source
{
get name() { return "discovery"; }
get estimated_items_per_page() { return 60; }
async load_page_internal(page)
{
// Get "mode" from the URL. If it's not present, use "all".
let mode = this.url.searchParams.get("mode") || "all";
let result = await helpers.get_request("/ajax/discovery/artworks", {
limit: this.estimated_items_per_page,
mode: mode,
lang: "en",
});
// result.body.recommendedIllusts[].recommendMethods, recommendSeedIllustIds
// has info about why it recommended it.
let thumbs = result.body.thumbnails.illust;
thumbnail_data.singleton().loaded_thumbnail_info(thumbs, "normal");
let illust_ids = [];
for(let thumb of thumbs)
illust_ids.push(thumb.id);
tag_translations.get().add_translations_dict(result.body.tagTranslation);
this.add_page(page, illust_ids);
};
get page_title() { return "Discovery"; }
get_displaying_text() { return "Recommended Works"; }
refresh_thumbnail_ui(container)
{
// Set .selected on the current mode.
let current_mode = this.url.searchParams.get("mode") || "all";
helpers.set_class(container.querySelector(".box-link[data-type=all]"), "selected", current_mode == "all");
helpers.set_class(container.querySelector(".box-link[data-type=safe]"), "selected", current_mode == "safe");
helpers.set_class(container.querySelector(".box-link[data-type=r18]"), "selected", current_mode == "r18");
}
}
// bookmark_detail.php#recommendations=1 - Similar Illustrations
//
// We use this as an anchor page for viewing recommended illusts for an image, since
// there's no dedicated page for this.
ppixiv.data_sources.related_illusts = class extends data_source
{
get name() { return "related-illusts"; }
get estimated_items_per_page() { return 60; }
async _load_page_async(page, cause)
{
// The first time we load a page, get info about the source illustration too, so
// we can show it in the UI.
if(!this.fetched_illust_info)
{
this.fetched_illust_info = true;
// Don't wait for this to finish before continuing.
let illust_id = this.url.searchParams.get("illust_id");
image_data.singleton().get_image_info(illust_id).then((illust_info) => {
this.illust_info = illust_info;
this.call_update_listeners();
}).catch((e) => {
console.error(e);
});
}
return await super._load_page_async(page, cause);
}
async load_page_internal(page)
{
// Get "mode" from the URL. If it's not present, use "all".
let mode = this.url.searchParams.get("mode") || "all";
let result = await helpers.get_request("/ajax/discovery/artworks", {
sampleIllustId: this.url.searchParams.get("illust_id"),
mode: mode,
limit: this.estimated_items_per_page,
lang: "en",
});
// result.body.recommendedIllusts[].recommendMethods, recommendSeedIllustIds
// has info about why it recommended it.
let thumbs = result.body.thumbnails.illust;
thumbnail_data.singleton().loaded_thumbnail_info(thumbs, "normal");
let illust_ids = [];
for(let thumb of thumbs)
illust_ids.push(thumb.id);
tag_translations.get().add_translations_dict(result.body.tagTranslation);
this.add_page(page, illust_ids);
};
get page_title() { return "Similar Illusts"; }
get_displaying_text() { return "Similar Illustrations"; }
refresh_thumbnail_ui(container)
{
// Set the source image.
var source_link = container.querySelector(".image-for-suggestions");
source_link.hidden = this.illust_info == null;
if(this.illust_info)
{
source_link.href = "/artworks/" + this.illust_info.illustId + "#ppixiv";
var img = source_link.querySelector(".image-for-suggestions > img");
img.src = this.illust_info.urls.thumb;
}
}
}
// Artist suggestions take a random sample of followed users, and query suggestions from them.
// The followed user list normally comes from /discovery/users.
//
// This can also be used to view recommendations based on a specific user. Note that if we're
// doing this, we don't show things like the artist's avatar in the corner, so it doesn't look
// like the images we're showing are by that user.
ppixiv.data_sources.discovery_users = class extends data_source
{
get name() { return "discovery_users"; }
constructor(url)
{
super(url);
let args = new helpers.args(this.url);
let user_id = args.hash.get("user_id");
if(user_id != null)
this.showing_user_id = user_id;
this.original_url = url;
this.seen_user_ids = {};
}
get users_per_page() { return 20; }
get estimated_items_per_page()
{
let illusts_per_user = this.showing_user_id != null? 3:5;
return this.users_per_page + (users_per_page * illusts_per_user);
}
async load_page_internal(page)
{
if(this.showing_user_id != null)
{
// Make sure the user info is loaded.
this.user_info = await image_data.singleton().get_user_info_full(this.showing_user_id);
// Update to refresh our page title, which uses user_info.
this.call_update_listeners();
}
// Get suggestions. Each entry is a user, and contains info about a small selection of
// images.
let result;
if(this.showing_user_id != null)
{
result = await helpers.get_request(\`/ajax/user/\${this.showing_user_id}/recommends\`, {
userNum: this.users_per_page,
workNum: 3,
isR18: true,
lang: "en"
});
} else {
result = await helpers.get_request("/ajax/discovery/users", {
limit: this.users_per_page,
lang: "en",
});
// This one includes tag translations.
tag_translations.get().add_translations_dict(result.body.tagTranslation);
}
if(result.error)
throw "Error reading suggestions: " + result.message;
thumbnail_data.singleton().loaded_thumbnail_info(result.body.thumbnails.illust, "normal");
for(let user of result.body.users)
{
image_data.singleton().add_user_data(user);
// Register this as quick user data, for use in thumbnails.
thumbnail_data.singleton().add_quick_user_data(user, "recommendations");
}
// Pixiv's motto: "never do the same thing the same way twice"
// ajax/user/#/recommends is body.recommendUsers and user.illustIds.
// discovery/users is body.recommendedUsers and user.recentIllustIds.
let recommended_users = result.body.recommendUsers || result.body.recommendedUsers;
let illust_ids = [];
for(let user of recommended_users)
{
// Each time we load a "page", we're actually just getting a new randomized set of recommendations
// for our seed, so we'll often get duplicate results. Ignore users that we've seen already. id_list
// will remove dupes, but we might get different sample illustrations for a duplicated artist, and
// those wouldn't be removed.
if(this.seen_user_ids[user.userId])
continue;
this.seen_user_ids[user.userId] = true;
illust_ids.push("user:" + user.userId);
let illustIds = user.illustIds || user.recentIllustIds;
for(let illust_id of illustIds)
illust_ids.push(illust_id);
}
// Register the new page of data.
this.add_page(page, illust_ids);
}
load_page_available(page)
{
// If we're showing similar users, only show one page, since the API returns the
// same thing every time.
if(this.showing_user_id)
return page == 1;
return true;
}
get estimated_items_per_page() { return 30; }
get page_title()
{
if(this.showing_user_id == null)
return "Recommended Users";
if(this.user_info)
return this.user_info.name;
else
return "Loading...";
}
get_displaying_text()
{
if(this.showing_user_id == null)
return "Recommended Users";
if(this.user_info)
return "Similar artists to " + this.user_info.name;
else
return "Illustrations";
};
refresh_thumbnail_ui(container)
{
}
};
// /ranking.php
//
// This one has an API, and also formats the first page of results into the page.
// They have completely different formats, and the page is updated dynamically (unlike
// the pages we scrape), so we ignore the page for this one and just use the API.
//
// An exception is that we load the previous and next days from the page. This is better
// than using our current date, since it makes sure we have the same view of time as
// the search results.
ppixiv.data_sources.rankings = class extends data_source
{
constructor(url)
{
super(url);
this.max_page = 999999;
}
get name() { return "rankings"; }
load_page_available(page)
{
return page <= this.max_page;
}
async load_page_internal(page)
{
/*
"mode": "daily",
"content": "all",
"page": 1,
"prev": false,
"next": 2,
"date": "20180923",
"prev_date": "20180922",
"next_date": false,
"rank_total": 500
*/
// Get "mode" from the URL. If it's not present, use "all".
var query_args = this.url.searchParams;
var data = {
format: "json",
p: page,
};
var date = query_args.get("date");
if(date)
data.date = date;
var content = query_args.get("content");
if(content)
data.content = content;
var mode = query_args.get("mode");
if(mode)
data.mode = mode;
var result = await helpers.get_request("/ranking.php", data);
// If "next" is false, this is the last page.
if(!result.next)
this.max_page = Math.min(page, this.max_page);
// Fill in the next/prev dates for the navigation buttons, and the currently
// displayed date.
if(this.today_text == null)
{
this.today_text = result.date;
// This is "YYYYMMDD". Reformat it.
if(this.today_text.length == 8)
{
var year = this.today_text.slice(0,4);
var month = this.today_text.slice(4,6);
var day = this.today_text.slice(6,8);
this.today_text = year + "/" + month + "/" + day;
}
}
if(this.prev_date == null && result.prev_date)
this.prev_date = result.prev_date;
if(this.next_date == null && result.next_date)
this.next_date = result.next_date;
// This returns a struct of data that's like the thumbnails data response,
// but it's not quite the same.
var illust_ids = [];
for(var item of result.contents)
{
// Most APIs return IDs as strings, but this one returns them as ints.
// Convert them to strings.
var illust_id = "" + item.illust_id;
var user_id = "" + item.user_id;
illust_ids.push(illust_id);
}
// Register this as thumbnail data.
thumbnail_data.singleton().loaded_thumbnail_info(result.contents, "rankings");
// Register the new page of data.
this.add_page(page, illust_ids);
};
get estimated_items_per_page() { return 50; }
get page_title() { return "Rankings"; }
get_displaying_text() { return "Rankings"; }
refresh_thumbnail_ui(container)
{
var query_args = this.url.searchParams;
this.set_item(container, "content-all", {content: null});
this.set_item(container, "content-illust", {content: "illust"});
this.set_item(container, "content-ugoira", {content: "ugoira"});
this.set_item(container, "content-manga", {content: "manga"});
this.set_item(container, "mode-daily", {mode: null}, {mode: "daily"});
this.set_item(container, "mode-daily-r18", {mode: "daily_r18"});
this.set_item(container, "mode-r18g", {mode: "r18g"});
this.set_item(container, "mode-weekly", {mode: "weekly"});
this.set_item(container, "mode-monthly", {mode: "monthly"});
this.set_item(container, "mode-rookie", {mode: "rookie"});
this.set_item(container, "mode-original", {mode: "original"});
this.set_item(container, "mode-male", {mode: "male"});
this.set_item(container, "mode-female", {mode: "female"});
if(this.today_text)
container.querySelector(".nav-today").innerText = this.today_text;
// This UI is greyed rather than hidden before we have the dates, so the UI doesn't
// shift around as we load.
var yesterday = container.querySelector(".nav-yesterday");
helpers.set_class(yesterday.querySelector(".box-link"), "disabled", this.prev_date == null);
if(this.prev_date)
{
let url = new URL(this.url);
url.searchParams.set("date", this.prev_date);
yesterday.querySelector("a").href = url;
}
var tomorrow = container.querySelector(".nav-tomorrow");
helpers.set_class(tomorrow.querySelector(".box-link"), "disabled", this.next_date == null);
if(this.next_date)
{
let url = new URL(this.url);
url.searchParams.set("date", this.next_date);
tomorrow.querySelector("a").href = url;
}
// Not all combinations of content and mode exist. For example, there's no ugoira
// monthly, and we'll get an error page if we load it. Hide navigations that aren't
// available. This isn't perfect: if you want to choose ugoira when you're on monthly
// you need to select a different time range first. We could have the content links
// switch to daily if not available...
var available_combinations = [
"all/daily",
"all/daily_r18",
"all/r18g",
"all/weekly",
"all/monthly",
"all/rookie",
"all/original",
"all/male",
"all/female",
"illust/daily",
"illust/daily_r18",
"illust/r18g",
"illust/weekly",
"illust/monthly",
"illust/rookie",
"ugoira/daily",
"ugoira/weekly",
"ugoira/daily_r18",
"manga/daily",
"manga/daily_r18",
"manga/r18g",
"manga/weekly",
"manga/monthly",
"manga/rookie",
];
// Check each link in both checked-links sections.
for(var a of container.querySelectorAll(".checked-links a"))
{
let url = new URL(a.href, this.url);
var link_content = url.searchParams.get("content") || "all";
var link_mode = url.searchParams.get("mode") || "daily";
var name = link_content + "/" + link_mode;
var available = available_combinations.indexOf(name) != -1;
var is_content_link = a.dataset.type.startsWith("content");
if(is_content_link)
{
// If this is a content link (eg. illustrations) and the combination of the
// current time range and this content type isn't available, make this link
// go to daily rather than hiding it, so all content types are always available
// and you don't have to switch time ranges just to select a different type.
if(!available)
{
url.searchParams.delete("mode");
a.href = url;
}
}
else
{
// If this is a mode link (eg. weekly) and it's not available, just hide
// the link.
a.hidden = !available;
}
}
}
}
// This is a base class for data sources that work by loading a regular Pixiv page
// and scraping it.
//
// All of these work the same way. We keep the current URL (ignoring the hash) synced up
// as a valid page URL that we can load. If we change pages or other search options, we
// modify the URL appropriately.
class data_source_from_page extends data_source
{
constructor(url)
{
super(url);
this.items_per_page = 1;
this.original_url = url;
}
get estimated_items_per_page() { return this.items_per_page; }
async load_page_internal(page)
{
// Our page URL looks like eg.
//
// https://www.pixiv.net/bookmark.php?p=2
//
// possibly with other search options. Request the current URL page data.
var url = new unsafeWindow.URL(this.url);
// Update the URL with the current page.
url.searchParams.set("p", page);
console.log("Loading:", url.toString());
let doc = await helpers.load_data_in_iframe(url);
let illust_ids = this.parse_document(doc);
if(illust_ids == null)
{
// The most common case of there being no data in the document is loading
// a deleted illustration. See if we can find an error message.
console.error("No data on page");
return;
}
// Assume that if the first request returns 10 items, all future pages will too. This
// is usually correct unless we happen to load the last page last. Allow this to increase
// in case that happens. (This is only used by the thumbnail view.)
if(this.items_per_page == 1)
this.items_per_page = Math.max(illust_ids.length, this.items_per_page);
// Register the new page of data.
this.add_page(page, illust_ids);
}
// Parse the loaded document and return the illust_ids.
parse_document(doc)
{
throw "Not implemented";
}
};
// - User illustrations
//
// /users/#
// /users/#/artworks
// /users/#/illustrations
// /users/#/manga
//
// We prefer to link to the /artworks page, but we handle /users/# as well.
ppixiv.data_sources.artist = class extends data_source
{
get name() { return "artist"; }
constructor(url)
{
super(url);
this.fanbox_url = null;
}
get supports_start_page() { return true; }
get viewing_user_id()
{
// /users/13245
return helpers.get_path_part(this.url, 1);
};
startup()
{
super.startup();
// While we're active, watch for the tags box to open. We only populate the tags
// dropdown if it's opened, so we don't load user tags for every user page.
var popup = document.body.querySelector(".member-tags-box > .popup-menu-box");
this.src_observer = new MutationObserver((mutation_list) => {
if(popup.classList.contains("popup-visible"))
this.tag_list_opened();
});
this.src_observer.observe(popup, { attributes: true });
}
shutdown()
{
super.shutdown();
// Remove our MutationObserver.
this.src_observer.disconnect();
this.src_observer = null;
}
// Return "artworks" (all), "illustrations" or "manga".
get viewing_type()
{
// The URL is one of:
//
// /users/12345
// /users/12345/artworks
// /users/12345/illustrations
// /users/12345/manga
//
// The top /users/12345 page is the user's profile page, which has the first page of images, but
// instead of having a link to page 2, it only has "See all", which goes to /artworks and shows you
// page 1 again. That's pointless, so we treat the top page as /artworks the same. /illustrations
// and /manga filter those types.
let url = helpers.get_url_without_language(this.url);
let parts = url.pathname.split("/");
return parts[3] || "artworks";
}
async load_page_internal(page)
{
let viewing_type = this.type;
// Make sure the user info is loaded. This should normally be preloaded by globalInitData
// in main.js, and this won't make a request.
this.user_info = await image_data.singleton().get_user_info_full(this.viewing_user_id);
// Update to refresh our page title, which uses user_info.
this.call_update_listeners();
let args = new helpers.args(this.url);
var tag = args.query.get("tag") || "";
if(tag == "")
{
// If we're not filtering by tag, use the profile/all request. This returns all of
// the user's illust IDs but no thumb data.
//
// We can use the "illustmanga" code path for this by leaving the tag empty, but
// we do it this way since that's what the site does.
if(this.pages == null)
{
let all_illust_ids = await this.load_all_results();
this.pages = paginate_illust_ids(all_illust_ids, this.estimated_items_per_page);
}
let illust_ids = this.pages[page-1] || [];
if(illust_ids.length)
{
// That only gives us a list of illust IDs, so we have to load them. Annoyingly, this
// is the one single place it gives us illust info in bulk instead of thumbnail data.
// It would be really useful to be able to batch load like this in general, but this only
// works for a single user's posts.
let url = \`/ajax/user/\${this.viewing_user_id}/profile/illusts\`;
let result = await helpers.get_request(url, {
"ids[]": illust_ids,
work_category: "illustManga",
is_first_page: "0",
});
let illusts = Object.values(result.body.works);
thumbnail_data.singleton().loaded_thumbnail_info(illusts, "normal");
}
// Don't do this. image_data assumes that if we have illust data, we want all data,
// like manga pages, and it'll make a request for each one to add the missing info.
// Just register it as thumbnail info.
// for(let illust_data in illusts)
// await image_data.singleton().add_illust_data(illust_data);
// Register this page.
this.add_page(page, illust_ids);
}
else
{
// We're filtering by tag.
var type = args.query.get("type");
// For some reason, this API uses a random field in the URL for the type instead of a normal
// query parameter.
var type_for_url =
type == null? "illustmanga":
type == "illust"?"illusts":
"manga";
var request_url = "/ajax/user/" + this.viewing_user_id + "/" + type_for_url + "/tag";
var result = await helpers.get_request(request_url, {
tag: tag,
offset: (page-1)*48,
limit: 48,
});
// This data doesn't have profileImageUrl or userName. That's presumably because it's
// used on user pages which get that from user data, but this seems like more of an
// inconsistency than an optimization. Fill it in for thumbnail_data.
for(var item of result.body.works)
{
item.userName = this.user_info.name;
item.profileImageUrl = this.user_info.imageBig;
}
var illust_ids = [];
for(var illust_data of result.body.works)
illust_ids.push(illust_data.id);
// This request returns all of the thumbnail data we need. Forward it to
// thumbnail_data so we don't need to look it up.
thumbnail_data.singleton().loaded_thumbnail_info(result.body.works, "normal");
// Register the new page of data.
this.add_page(page, illust_ids);
}
}
add_extra_links(links)
{
// Add the Fanbox link to the list if we have one.
if(this.fanbox_url)
links.push(this.fanbox_url);
}
async load_all_results()
{
this.call_update_listeners();
var query_args = this.url.searchParams;
let type = this.viewing_type;
var result = await helpers.get_request("/ajax/user/" + this.viewing_user_id + "/profile/all", {});
// See if there's a Fanbox link.
//
// For some reason Pixiv supports links to Twitter and Pawoo natively in the profile, but Fanbox
// can only be linked in this weird way outside the regular user profile info.
for(let pickup of result.body.pickup)
{
if(pickup.type != "fanbox")
continue;
// Remove the Google analytics junk from the URL.
let url = new URL(pickup.contentUrl);
url.search = "";
this.fanbox_url = url.toString();
this.call_update_listeners();
}
var illust_ids = [];
if(type == "artworks" || type == "illustrations")
for(var illust_id in result.body.illusts)
illust_ids.push(illust_id);
if(type == "artworks" || type == "manga")
for(var illust_id in result.body.manga)
illust_ids.push(illust_id);
// Sort the two sets of IDs back together, putting higher (newer) IDs first.
illust_ids.sort(function(lhs, rhs)
{
return parseInt(rhs) - parseInt(lhs);
});
return illust_ids;
};
refresh_thumbnail_ui(container, thumbnail_view)
{
thumbnail_view.avatar_widget.visible = true;
if(this.user_info)
thumbnail_view.avatar_widget.set_user_id(this.viewing_user_id);
let viewing_type = this.viewing_type;
let url = new URL(this.url);
this.set_path_item(container, "artist-works", 2, "");
this.set_path_item(container, "artist-illust", 2, "illustrations");
this.set_path_item(container, "artist-manga", 2, "manga");
// Refresh the post tag list.
var query_args = this.url.searchParams;
var current_query = query_args.toString();
var tag_list = container.querySelector(".post-tag-list");
helpers.remove_elements(tag_list);
var add_tag_link = (tag_info) =>
{
// Skip tags with very few posts. This list includes every tag the author
// has ever used, and ends up being pages long with tons of tags that were
// only used once.
if(tag_info.tag != "All" && tag_info.cnt < 5)
return;
let tag = tag_info.tag;
let translated_tag = tag;
if(this.translated_tags[tag])
translated_tag = this.translated_tags[tag];
var a = document.createElement("a");
a.classList.add("box-link");
a.classList.add("following-tag");
a.innerText = translated_tag;
// Show the post count in the popup.
a.classList.add("popup");
a.dataset.popup = tag_info.cnt;
let url = new URL(this.url);
url.hash = "#ppixiv";
if(tag != "All")
url.searchParams.set("tag", tag);
else
{
url.searchParams.delete("tag");
a.dataset["default"] = 1;
}
a.href = url.toString();
if(url.searchParams.toString() == current_query)
a.classList.add("selected");
tag_list.appendChild(a);
};
if(this.post_tags != null)
{
add_tag_link({ tag: "All" });
for(let tag_info of this.post_tags || [])
add_tag_link(tag_info);
}
else
{
// Tags aren't loaded yet. We'll be refreshed after tag_list_opened loads tags.
var span = document.createElement("span");
span.innerText = "Loading...";
tag_list.appendChild(span);
}
// Set whether the tags menu item is highlighted. We don't use set_active_popup_highlight
// here so we don't need to load the tag list.
var box = container.querySelector(".member-tags-box");
helpers.set_class(box, "active", query_args.has("tag"));
}
// This is called when the tag list dropdown is opened.
async tag_list_opened()
{
// Get user info. We probably have this on this.user_info, but that async load
// might not be finished yet.
var user_info = await image_data.singleton().get_user_info_full(this.viewing_user_id);
console.log("Loading tags for user", user_info.userId);
// Load the user's common tags.
this.post_tags = await this.get_user_tags(user_info);
let tags = [];
for(let tag_info of this.post_tags)
tags.push(tag_info.tag);
this.translated_tags = await tag_translations.get().get_translations(tags, "en");
// If we became inactive before the above request finished, stop.
if(!this.active)
return;
// Trigger refresh_thumbnail_ui to fill in tags.
this.call_update_listeners();
}
async get_user_tags(user_info)
{
if(user_info.frequentTags)
return user_info.frequentTags;
var result = await helpers.get_request("https://www.pixiv.net/ajax/user/" + user_info.userId + "/illustmanga/tags", {});
if(result.error)
{
console.error("Error fetching tags for user " + user_info.userId + ": " + result.error);
user_info.frequentTags = [];
return user_info.frequentTags;
}
// Sort most frequent tags first.
result.body.sort(function(lhs, rhs) {
return rhs.cnt - lhs.cnt;
})
// Store translations.
let translations = [];
for(let tag_info of result.body)
{
if(tag_info.tag_translation == "")
continue;
translations.push({
tag: tag_info.tag,
translation: {
en: tag_info.tag_translation,
},
});
}
tag_translations.get().add_translations(translations);
// Cache the results on the user info.
user_info.frequentTags = result.body;
return result.body;
}
get page_title()
{
if(this.user_info)
return this.user_info.name;
else
return "Loading...";
}
get_displaying_text()
{
if(this.user_info)
return this.user_info.name + "'s Illustrations";
else
return "Illustrations";
};
}
// /artworks/# - Viewing a single illustration
//
// This is a stub for when we're viewing an image with no search. it
// doesn't return any search results.
ppixiv.data_sources.current_illust = class extends data_source
{
get name() { return "illust"; }
constructor(url)
{
super(url);
// /artworks/#
url = new URL(url);
url = helpers.get_url_without_language(url);
let parts = url.pathname.split("/");
this.illust_id = parts[2];
}
// Show the illustration by default.
get default_screen()
{
return "illust";
}
// This data source just views a single image and doesn't return any posts.
async load_page_internal(page) { }
// We're always viewing our illust_id.
get_current_illust_id() { return this.illust_id; }
// We don't return any posts to navigate to, but this can still be called by
// quick view.
set_current_illust_id(illust_id, args)
{
// Pixiv's inconsistent URLs are annoying. Figure out where the ID field is.
// If the first field is a language, it's the third field (/en/artworks/#), otherwise
// it's the second (/artworks/#).
let parts = args.path.split("/");
let id_part = parts[1].length == 2? 3:2;
parts[id_part] = illust_id;
args.path = parts.join("/");
}
get viewing_user_id()
{
if(this.user_info == null)
return null;
return this.user_info.userId;
};
};
// bookmark.php
// /users/12345/bookmarks
//
// If id is in the query, we're viewing another user's bookmarks. Otherwise, we're
// viewing our own.
//
// Pixiv currently serves two unrelated pages for this URL, using an API-driven one
// for viewing someone else's bookmarks and a static page for viewing your own. We
// always use the API in either case.
//
// For some reason, Pixiv only allows viewing either public or private bookmarks,
// and has no way to just view all bookmarks.
class data_source_bookmarks_base extends data_source
{
get name() { return "bookmarks"; }
constructor(url)
{
super(url);
this.bookmark_tag_counts = [];
// The subclass sets this once it knows the number of bookmarks in this search.
this.total_bookmarks = -1;
}
async load_page_internal(page)
{
this.fetch_bookmark_tag_counts();
// Load the user's info. We don't need to wait for this to finish.
let user_info_promise = image_data.singleton().get_user_info_full(this.viewing_user_id);
user_info_promise.then((user_info) => {
// Stop if we were deactivated before this finished.
if(!this.active)
return;
this.user_info = user_info;
this.call_update_listeners();
});
await this.continue_loading_page_internal(page);
};
get supports_start_page()
{
// Disable start pages when we're shuffling pages anyway.
return !this.shuffle;
}
get displaying_tag()
{
let url = helpers.get_url_without_language(this.url);
let parts = url.pathname.split("/");
if(parts.length < 6)
return null;
// Replace 未分類 with "" for uncategorized.
let tag = decodeURIComponent(parts[5]);
if(tag == "未分類")
return "";
return tag;
}
// If we haven't done so yet, load bookmark tags for this bookmark page. This
// happens in parallel with with page loading.
async fetch_bookmark_tag_counts()
{
if(this.fetched_bookmark_tag_counts)
return;
this.fetched_bookmark_tag_counts = true;
// If we have cached bookmark counts for ourself, load them.
if(this.viewing_own_bookmarks() && data_source_bookmarks_base.cached_bookmark_tag_counts != null)
this.load_bookmark_tag_counts(data_source_bookmarks_base.cached_bookmark_tag_counts);
// Fetch bookmark tags. We can do this in parallel with everything else.
var url = "https://www.pixiv.net/ajax/user/" + this.viewing_user_id + "/illusts/bookmark/tags";
var result = await helpers.get_request(url, {});
// Cache this if we're viewing our own bookmarks, so we can display them while
// navigating bookmarks. We'll still refresh it as each page loads.
if(this.viewing_own_bookmarks())
data_source_bookmarks_base.cached_bookmark_tag_counts = result.body;
this.load_bookmark_tag_counts(result.body);
}
load_bookmark_tag_counts(result)
{
let public_bookmarks = this.viewing_public;
let private_bookmarks = this.viewing_private;
// Reformat the tag list into a format that's easier to work with.
let tags = { };
for(let privacy of ["public", "private"])
{
let public_tags = privacy == "public";
if((public_tags && !public_bookmarks) ||
(!public_tags && !private_bookmarks))
continue;
let tag_counts = result[privacy];
for(let tag_info of tag_counts)
{
let tag = tag_info.tag;
// Rename "未分類" (uncategorized) to "".
if(tag == "未分類")
tag = "";
if(tags[tag] == null)
tags[tag] = 0;
// Add to the tag count.
tags[tag] += tag_info.cnt;
}
}
// Fill in total_bookmarks from the tag count. We'll get this from the search API,
// but we can have it here earlier if we're viewing our own bookmarks and
// cached_bookmark_tag_counts is filled in. We can't do this when viewing all bookmarks
// (summing the counts will give the wrong answer whenever multiple tags are used on
// one bookmark).
let displaying_tag = this.displaying_tag;
if(displaying_tag != null && this.total_bookmarks == -1)
{
let count = tags[displaying_tag];
if(count != null)
this.total_bookmarks = count;
}
// Sort tags by count, so we can trim just the most used tags. Use the count for the
// display mode we're in.
var all_tags = Object.keys(tags);
all_tags.sort(function(lhs, rhs) {
return tags[lhs].count - tags[lhs].count;
});
if(!this.viewing_own_bookmarks())
{
// Trim the list when viewing other users. Some users will return thousands of tags.
all_tags.splice(20);
}
all_tags.sort();
this.bookmark_tag_counts = {};
for(let tag of all_tags)
this.bookmark_tag_counts[tag] = tags[tag];
// Update the UI with the tag list.
this.call_update_listeners();
}
// Get API arguments to query bookmarks.
//
// If force_rest isn't null, it's either "show" (public) or "hide" (private), which
// overrides the search parameters.
get_bookmark_query_params(page, force_rest)
{
var query_args = this.url.searchParams;
var rest = query_args.get("rest") || "show";
if(force_rest != null)
rest = force_rest;
let tag = this.displaying_tag;
if(tag == "")
tag = "未分類"; // Uncategorized
else if(tag == null)
tag = "";
// Load 20 results per page, so our page numbers should match the underlying page if
// the UI is disabled.
return {
tag: tag,
offset: (page-1)*this.estimated_items_per_page,
limit: this.estimated_items_per_page,
rest: rest, // public or private (no way to get both)
};
}
async request_bookmarks(page, rest)
{
let data = this.get_bookmark_query_params(page, rest);
let url = \`/ajax/user/\${this.viewing_user_id}/illusts/bookmarks\`;
let result = await helpers.get_request(url, data);
// This request includes each bookmark's tags. Register those with image_data,
// so the bookmark tag dropdown can display tags more quickly.
for(let illust of result.body.works)
{
let bookmark_id = illust.bookmarkData.id;
let tags = result.body.bookmarkTags[bookmark_id] || [];
image_data.singleton().update_cached_bookmark_image_tags(illust.id, tags);
}
result.body.works = data_source_bookmarks_base.filter_deleted_images(result.body.works);
return result.body;
}
// This is implemented by the subclass to do the main loading.
async continue_loading_page_internal(page)
{
throw "Not implemented";
}
get page_title()
{
if(!this.viewing_own_bookmarks())
{
if(this.user_info)
return this.user_info.name + "'s Bookmarks";
else
return "Loading...";
}
return "Bookmarks";
}
get_displaying_text()
{
if(!this.viewing_own_bookmarks())
{
if(this.user_info)
return this.user_info.name + "'s Bookmarks";
return "User's Bookmarks";
}
let args = new helpers.args(this.url);
let public_bookmarks = this.viewing_public;
let private_bookmarks = this.viewing_private;
let viewing_all = public_bookmarks && private_bookmarks;
var displaying = "";
if(this.total_bookmarks != -1)
displaying += this.total_bookmarks + " ";
displaying += viewing_all? "Bookmark":
private_bookmarks? "Private Bookmark":"Public Bookmark";
// English-centric pluralization:
if(this.total_bookmarks != 1)
displaying += "s";
var tag = this.displaying_tag;
if(tag == "")
displaying += \` with no tags\`;
else if(tag != null)
displaying += \` with tag "\${tag}"\`;
return displaying;
};
// Return true if we're viewing publig and private bookmarks. These are overridden
// in bookmarks_merged.
get viewing_public()
{
let args = new helpers.args(this.url);
return args.query.get("rest") != "hide";
}
get viewing_private()
{
let args = new helpers.args(this.url);
return args.query.get("rest") == "hide";
}
refresh_thumbnail_ui(container, thumbnail_view)
{
// The public/private button only makes sense when viewing your own bookmarks.
var public_private_button_container = container.querySelector(".bookmarks-public-private");
public_private_button_container.hidden = !this.viewing_own_bookmarks();
// Set up the public and private buttons. The "all" button also removes shuffle, since it's not
// supported there.
this.set_item(public_private_button_container, "all", {"#show-all": 1, "#shuffle": null}, {"#show-all": 1});
this.set_item(container, "public", {rest: null, "#show-all": 0}, {"#show-all": 1});
this.set_item(container, "private", {rest: "hide", "#show-all": 0}, {"#show-all": 1});
// Shuffle isn't supported for merged bookmarks. If we're on #show-all, make the shuffle button
// also switch to public bookmarks. This is easier than graying it out and trying to explain it
// in the popup, and better than hiding it which makes it hard to find.
let args = new helpers.args(this.url);
let show_all = args.hash.get("show-all") != "0";
let set_public = show_all? { rest: null, "#show-all": 0 }:{};
this.set_item(container, "order-date", {"#shuffle": null}, {"#shuffle": null});
this.set_item(container, "order-shuffle", {"#shuffle": 1, ...set_public}, {"#shuffle": null, "#show-all": 1});
// Refresh the bookmark tag list. Remove the page number from these buttons.
let current_url = new URL(this.url);
current_url.searchParams.delete("p");
var tag_list = container.querySelector(".bookmark-tag-list");
let current_tag = this.displaying_tag;
helpers.remove_elements(tag_list);
var add_tag_link = (tag) =>
{
let tag_count = this.bookmark_tag_counts[tag];
var a = document.createElement("a");
a.classList.add("box-link");
a.classList.add("following-tag");
let tag_name = tag;
if(tag_name == null)
tag_name = "All";
else if(tag_name == "")
tag_name = "Uncategorized";
a.innerText = tag_name;
// Show the bookmark count in the popup.
if(tag_count != null)
{
a.classList.add("popup");
a.dataset.popup = tag_count + (tag_count == 1? " bookmark":" bookmarks");
}
let url = new URL(this.url);
url.searchParams.delete("p");
if(tag == current_tag)
a.classList.add("selected");
// Pixiv used to put the tag in a nice, clean query parameter, but recently got
// a bit worse and put it in the query. That's a much worse way to do things:
// it's harder to parse, and means you bake one particular feature into your
// URLs.
let old_pathname = helpers.get_url_without_language(url).pathname;
let parts = old_pathname.split("/");
if(tag == "")
tag = "未分類"; // Uncategorized
if(tag == null) // All
{
if(parts.length == 6)
parts = parts.splice(0,5);
}
else
{
if(parts.length < 6)
parts.push("");
parts[5] = encodeURIComponent(tag);
}
url.pathname = parts.join("/");
a.href = url.toString();
tag_list.appendChild(a);
};
add_tag_link(null); // All
add_tag_link(""); // Uncategorized
for(var tag of Object.keys(this.bookmark_tag_counts))
{
// Skip uncategorized, which is always placed at the beginning.
if(tag == "")
continue;
if(this.bookmark_tag_counts[tag] == 0)
continue;
add_tag_link(tag);
}
thumbnail_view.avatar_widget.visible = true;
thumbnail_view.avatar_widget.set_user_id(this.viewing_user_id);
}
get viewing_user_id()
{
// /users/13245/bookmarks
//
// This is currently only used for viewing other people's bookmarks. Your own bookmarks are still
// viewed with /bookmark.php with no ID.
return helpers.get_path_part(this.url, 1);
};
// Return true if we're viewing our own bookmarks.
viewing_own_bookmarks()
{
return this.viewing_user_id == window.global_data.user_id;
}
// Don't show bookmark icons for the user's own bookmarks. Every image on that page
// is bookmarked, so it's just a lot of noise.
get show_bookmark_icons()
{
return !this.viewing_own_bookmarks();
}
// Bookmark results include deleted images. These are weird and a bit broken:
// the post ID is an integer instead of a string (which makes more sense but is
// inconsistent with other results) and the data is mostly empty or garbage.
// Check isBookmarkable to filter these out.
static filter_deleted_images(images)
{
let result = [];
for(let image of images)
{
if(!image.isBookmarkable)
{
console.log("Discarded deleted bookmark " + image.id);
continue;
}
result.push(image);
}
return result;
}
}
// Normal bookmark querying. This can only retrieve public or private bookmarks,
// and not both.
ppixiv.data_sources.bookmarks = class extends data_source_bookmarks_base
{
get shuffle()
{
let args = new helpers.args(this.url);
return args.hash.has("shuffle");
}
async continue_loading_page_internal(page)
{
let page_to_load = page;
if(this.shuffle)
{
// We need to know the number of pages in order to shuffle, so load the first page.
// This is why we don't support this for merged bookmark loading: we'd need to load
// both first pages, then both first shuffled pages, so we'd be making four bookmark
// requests all at once.
if(this.total_shuffled_bookmarks == null)
{
let result = await this.request_bookmarks(1, null);
this.total_shuffled_bookmarks = result.total;
this.total_pages = Math.ceil(this.total_shuffled_bookmarks / this.estimated_items_per_page);
// Create a shuffled page list.
this.shuffled_pages = [];
for(let p = 1; p <= this.total_pages; ++p)
this.shuffled_pages.push(p);
helpers.shuffle_array(this.shuffled_pages);
}
if(page < this.shuffled_pages.length)
page_to_load = this.shuffled_pages[page];
}
let result = await this.request_bookmarks(page_to_load, null);
var illust_ids = [];
for(let illust_data of result.works)
illust_ids.push(illust_data.id);
// If we're shuffling, shuffle the individual illustrations too.
if(this.shuffle)
helpers.shuffle_array(illust_ids);
// This request returns all of the thumbnail data we need. Forward it to
// thumbnail_data so we don't need to look it up.
thumbnail_data.singleton().loaded_thumbnail_info(result.works, "normal");
// Register the new page of data. If we're shuffling, use the original page number, not the
// shuffled page.
this.add_page(page, illust_ids);
// Remember the total count, for display.
this.total_bookmarks = result.total;
}
};
// Merged bookmark querying. This makes queries for both public and private bookmarks,
// and merges them together.
ppixiv.data_sources.bookmarks_merged = class extends data_source_bookmarks_base
{
get viewing_public() { return true; }
get viewing_private() { return true; }
constructor(url)
{
super(url);
this.max_page_per_type = [-1, -1]; // public, private
this.bookmark_illust_ids = [[], []]; // public, private
this.bookmark_totals = [0, 0]; // public, private
}
async continue_loading_page_internal(page)
{
// Request both the public and private bookmarks on the given page. If we've
// already reached the end of either of them, don't send that request.
let request1 = this.request_bookmark_type(page, "show");
let request2 = this.request_bookmark_type(page, "hide");
// Wait for both requests to finish.
await Promise.all([request1, request2]);
// Both requests finished. Combine the two lists of illust IDs into a single page
// and register it.
var illust_ids = [];
for(var i = 0; i < 2; ++i)
if(this.bookmark_illust_ids[i] != null && this.bookmark_illust_ids[i][page] != null)
illust_ids = illust_ids.concat(this.bookmark_illust_ids[i][page]);
this.add_page(page, illust_ids);
// Combine the two totals.
this.total_bookmarks = this.bookmark_totals[0] + this.bookmark_totals[1];
}
async request_bookmark_type(page, rest)
{
var is_private = rest == "hide"? 1:0;
var max_page = this.max_page_per_type[is_private];
if(max_page != -1 && page > max_page)
{
// We're past the end.
console.log("page", page, "beyond", max_page, rest);
return;
}
let result = await this.request_bookmarks(page, rest);
// Put higher (newer) bookmarks first.
result.works.sort(function(lhs, rhs)
{
return parseInt(rhs.bookmarkData.id) - parseInt(lhs.bookmarkData.id);
});
var illust_ids = [];
for(let illust_data of result.works)
illust_ids.push(illust_data.id);
// This request returns all of the thumbnail data we need. Forward it to
// thumbnail_data so we don't need to look it up.
thumbnail_data.singleton().loaded_thumbnail_info(result.works, "normal");
// If there are no results, remember that this is the last page, so we don't
// make more requests for this type.
if(illust_ids.length == 0)
{
if(this.max_page_per_type[is_private] == -1)
this.max_page_per_type[is_private] = page;
else
this.max_page_per_type[is_private] = Math.min(page, this.max_page_per_type[is_private]);
// console.log("max page for", is_private? "private":"public", this.max_page_per_type[is_private]);
}
// Store the IDs. We don't register them here.
this.bookmark_illust_ids[is_private][page] = illust_ids;
// Remember the total count, for display.
this.bookmark_totals[is_private] = result.total;
}
}
// new_illust.php
ppixiv.data_sources.new_illust = class extends data_source
{
get name() { return "new_illust"; }
get page_title()
{
return "New Works";
}
get_displaying_text()
{
return "New Works";
};
async load_page_internal(page)
{
let args = new helpers.args(this.url);
// new_illust.php or new_illust_r18.php:
let r18 = this.url.pathname == "/new_illust_r18.php";
var type = args.query.get("type") || "illust";
// Everything Pixiv does has always been based on page numbers, but this one uses starting IDs.
// That's a better way (avoids duplicates when moving forward in the list), but it's inconsistent
// with everything else. We usually load from page 1 upwards. If we're loading the next page and
// we have a previous last_id, assume it starts at that ID.
//
// This makes some assumptions about how we're called: that we won't be called for the same page
// multiple times and we're always loaded in ascending order. In practice this is almost always
// true. If Pixiv starts using this method for more important pages it might be worth checking
// this more carefully.
if(this.last_id == null)
{
this.last_id = 0;
this.last_id_page = 1;
}
if(this.last_id_page != page)
{
console.error("Pages weren't loaded in order");
return;
}
console.log("Assuming page", page, "starts at", this.last_id);
var url = "/ajax/illust/new";
var result = await helpers.get_request(url, {
limit: 20,
type: type,
r18: r18,
lastId: this.last_id,
});
var illust_ids = [];
for(var illust_data of result.body.illusts)
illust_ids.push(illust_data.id);
if(illust_ids.length > 0)
{
this.last_id = illust_ids[illust_ids.length-1];
this.last_id_page++;
}
// This request returns all of the thumbnail data we need. Forward it to
// thumbnail_data so we don't need to look it up.
thumbnail_data.singleton().loaded_thumbnail_info(result.body.illusts, "normal");
// Register the new page of data.
this.add_page(page, illust_ids);
}
refresh_thumbnail_ui(container)
{
this.set_item(container, "new-illust-type-illust", {type: null});
this.set_item(container, "new-illust-type-manga", {type: "manga"});
// These links are different from anything else on the site: they switch between
// two top-level pages, even though they're just flags and everything else is the
// same. We don't actually need to do this since we're just making API calls, but
// we try to keep the base URLs compatible, so we go to the equivalent page on Pixiv
// if we're turned off.
var all_ages_link = container.querySelector("[data-type='new-illust-ages-all']");
var r18_link = container.querySelector("[data-type='new-illust-ages-r18']");
let url = new URL(this.url);
url.pathname = "/new_illust.php";
all_ages_link.href = url;
url = new URL(this.url);
url.pathname = "/new_illust_r18.php";
r18_link.href = url;
url = new URL(this.url);
var currently_all_ages = url.pathname == "/new_illust.php";
helpers.set_class(all_ages_link, "selected", currently_all_ages);
helpers.set_class(r18_link, "selected", !currently_all_ages);
}
}
// bookmark_new_illust.php, bookmark_new_illust_r18.php
ppixiv.data_sources.bookmarks_new_illust = class extends data_source
{
get name() { return "bookmarks_new_illust"; }
constructor(url)
{
super(url);
this.bookmark_tags = [];
}
async load_page_internal(page)
{
let current_tag = this.url.searchParams.get("tag") || "";
let r18 = this.url.pathname == "/bookmark_new_illust_r18.php";
let result = await helpers.get_request("/ajax/follow_latest/illust", {
p: page,
tag: current_tag,
mode: r18? "r18":"all",
});
let data = result.body;
// Add translations.
tag_translations.get().add_translations_dict(data.tagTranslation);
// Store bookmark tags.
this.bookmark_tags = data.page.tags;
// Populate thumbnail data with this data.
thumbnail_data.singleton().loaded_thumbnail_info(data.thumbnails.illust, "normal");
let illust_ids = [];
for(let illust of data.thumbnails.illust)
illust_ids.push(illust.id);
// Register the new page of data.
this.add_page(page, illust_ids);
}
get page_title()
{
return "Following";
}
get_displaying_text()
{
return "Following";
};
refresh_thumbnail_ui(container)
{
// Refresh the bookmark tag list.
let current_tag = this.url.searchParams.get("tag") || "All";
var tag_list = container.querySelector(".follow-new-post-tag-list");
helpers.remove_elements(tag_list);
let add_tag_link = (tag) =>
{
var a = document.createElement("a");
a.classList.add("box-link");
a.classList.add("following-tag");
a.innerText = tag;
var url = new URL(this.url);
if(tag != "All")
url.searchParams.set("tag", tag);
else
url.searchParams.delete("tag");
a.href = url.toString();
if(tag == current_tag)
a.classList.add("selected");
tag_list.appendChild(a);
};
add_tag_link("All");
for(var tag of this.bookmark_tags)
add_tag_link(tag);
var all_ages_link = container.querySelector("[data-type='bookmarks-new-illust-all']");
var r18_link = container.querySelector("[data-type='bookmarks-new-illust-ages-r18']");
var url = new URL(this.url);
url.pathname = "/bookmark_new_illust.php";
all_ages_link.href = url;
var url = new URL(this.url);
url.pathname = "/bookmark_new_illust_r18.php";
r18_link.href = url;
var url = new URL(this.url);
var currently_all_ages = url.pathname == "/bookmark_new_illust.php";
helpers.set_class(all_ages_link, "selected", currently_all_ages);
helpers.set_class(r18_link, "selected", !currently_all_ages);
}
};
// /tags
//
// The new tag search UI is a bewildering mess:
//
// - Searching for a tag goes to "/tags/TAG/artworks". This searches all posts with the
// tag. The API query is "/ajax/search/artworks/TAG". The "top" tab is highlighted, but
// it's not actually on that tab and no tab button goes back here. "Illustrations, Manga,
// Ugoira" in search options also goes here.
//
// - The "Illustrations" tab goes to "/tags/TAG/illustrations". The API is
// "/ajax/search/illustrations/TAG?type=illust_and_ugoira". This is almost identical to
// "artworks", but excludes posts marked as manga. "Illustrations, Ugoira" in search
// options also goes here.
//
// - Clicking "manga" goes to "/tags/TAG/manga". The API is "/ajax/search/manga" and also
// sets type=manga. This is "Manga" in the search options. This page is also useless.
//
// The "manga only" and "exclude manga" pages are useless, since Pixiv doesn't make any
// useful distinction between "manga" and "illustrations with more than one page". We
// only include them for completeness.
//
// - You can search for just animations, but there's no button for it in the UI. You
// have to pick it from the dropdown in search options. This one is "illustrations?type=ugoira".
// Why did they keep using type just for one search mode? Saying "type=manga" or any
// other type fails, so it really is just used for this.
//
// - Clicking "Top" goes to "/tags/TAG" with no type. This is a completely different
// page and API, "/ajax/search/top/TAG". It doesn't actually seem to be a rankings
// page and just shows the same thing as the others with a different layout, so we
// ignore this and treat it like "artworks".
ppixiv.data_sources.search = class extends data_source
{
get name() { return "search"; }
constructor(url)
{
super(url);
this.cache_search_title = this.cache_search_title.bind(this);
// Add the search tags to tag history. We only do this at the start when the
// data source is created, not every time we navigate back to the search.
let tag = this._search_tags;
if(tag)
helpers.add_recent_search_tag(tag);
this.cache_search_title();
}
get _search_tags()
{
return helpers._get_search_tags_from_url(this.url);
}
// Return the search type from the URL. This is one of "artworks", "illustrations"
// or "novels" (not supported). It can also be omitted, which is the "top" page,
// but that gives the same results as "artworks" with a different page layout, so
// we treat it as "artworks".
get _search_type()
{
// ["", "tags", tag list, type]
let url = helpers.get_url_without_language(this.url);
let parts = url.pathname.split("/");
if(parts.length >= 4)
return parts[3];
else
return "artworks";
}
startup()
{
super.startup();
// Refresh our title when translations are toggled.
settings.register_change_callback("disable-translations", this.cache_search_title);
}
shutdown()
{
super.shutdown();
settings.unregister_change_callback("disable-translations", this.cache_search_title);
}
async cache_search_title()
{
this.title = "Search: ";
let tags = this._search_tags;
if(tags)
{
tags = await tag_translations.get().translate_tag_list(tags, "en");
var tag_list = document.createElement("span");
for(let tag of tags)
{
// Force "or" lowercase.
if(tag.toLowerCase() == "or")
tag = "or";
var span = document.createElement("span");
span.innerText = tag;
span.classList.add("word");
if(tag == "or")
span.classList.add("or");
else
span.classList.add("tag");
tag_list.appendChild(span);
}
this.title += tags.join(" ");
this.displaying_tags = tag_list;
}
// Update our page title.
this.call_update_listeners();
}
async load_page_internal(page)
{
var query_args = this.url.searchParams;
let args = {
p: page,
};
// "artworks" and "illustrations" are different on the search page: "artworks" uses "/tag/TAG/artworks",
// and "illustrations" is "/tag/TAG/illustrations?type=illust_and_ugoira".
let search_type = this._search_type;
let api_search_type = "artworks";
if(search_type == "artworks")
{
// "artworks" doesn't use the type field.
api_search_type = "artworks";
}
else
if(search_type == "illustrations")
{
api_search_type = "illustrations";
args.type = "illust_and_ugoira";
}
else if(search_type == "manga")
{
api_search_type = "manga";
args.type = "manga";
}
query_args.forEach((value, key) => { args[key] = value; });
let tag = this._search_tags;
// If we have no tags, we're probably on the "/tags" page, which is just a list of tags. Don't
// run a search with no tags.
if(!tag)
{
console.log("No search tags");
return;
}
var url = "/ajax/search/" + api_search_type + "/" + encodeURIComponent(tag);
var result = await helpers.get_request(url, args);
let body = result.body;
// Store related tags. Only do this the first time and don't change it when we read
// future pages, so the tags don't keep changing as you scroll around.
if(this.related_tags == null)
{
this.related_tags = [];
for(let tag of body.relatedTags)
this.related_tags.push({tag: tag});
this.call_update_listeners();
}
// Add translations.
let translations = [];
for(let tag of Object.keys(body.tagTranslation))
{
translations.push({
tag: tag,
translation: body.tagTranslation[tag],
});
}
tag_translations.get().add_translations(translations);
// /tag/TAG/illustrations returns results in body.illust.
// /tag/TAG/artworks returns results in body.illustManga.
// /tag/TAG/manga returns results in body.manga.
let illusts = body.illust || body.illustManga || body.manga;
illusts = illusts.data;
// Populate thumbnail data with this data.
thumbnail_data.singleton().loaded_thumbnail_info(illusts, "normal");
var illust_ids = [];
for(let illust of illusts)
illust_ids.push(illust.id);
// Register the new page of data.
this.add_page(page, illust_ids);
}
get page_title()
{
return this.title;
}
get_displaying_text()
{
return this.displaying_tags;
};
initial_refresh_thumbnail_ui(container, view)
{
// Fill the search box with the current tag.
var query_args = this.url.searchParams;
let tag = this._search_tags;
container.querySelector(".search-page-tag-entry .search-tags").value = tag;
}
// Return the search mode, which is selected by the "Type" search option. This generally
// corresponds to the underlying page's search modes.
get_url_search_mode()
{
// "/tags/tag/illustrations" has a "type" parameter with the search type. This is used for
// "illust" (everything except animations) and "ugoira".
let search_type = this._search_type;
if(search_type == "illustrations")
{
let query_search_type = this.url.searchParams.get("type");
if(query_search_type == "ugoira") return "ugoira";
if(query_search_type == "illust") return "illust";
// If there's no parameter, show everything.
return "all";
}
if(search_type == "artworks")
return "all";
if(search_type == "manga")
return "manga";
// Use "all" for unrecognized types.
return "all";
}
// Return URL with the search mode set to mode.
set_url_search_mode(url, mode)
{
url = new URL(url);
url = helpers.get_url_without_language(url);
// Only "ugoira" searches use type in the query. It causes an error in other modes, so remove it.
if(mode == "illust")
url.searchParams.set("type", "illust");
else if(mode == "ugoira")
url.searchParams.set("type", "ugoira");
else
url.searchParams.delete("type");
let search_type = "artworks";
if(mode == "manga")
search_type = "manga";
else if(mode == "ugoira" || mode == "illust")
search_type = "illustrations";
// Set the type in the URL.
let parts = url.pathname.split("/");
parts[3] = search_type;
url.pathname = parts.join("/");
return url;
}
refresh_thumbnail_ui(container, thumbnail_view)
{
if(this.related_tags)
{
thumbnail_view.tag_widget.set({
tags: this.related_tags
});
}
this.set_item(container, "ages-all", {mode: null});
this.set_item(container, "ages-safe", {mode: "safe"});
this.set_item(container, "ages-r18", {mode: "r18"});
this.set_item(container, "order-newest", {order: null}, {order: "date_d"});
this.set_item(container, "order-oldest", {order: "date"});
this.set_item(container, "order-all", {order: "popular_d"});
this.set_item(container, "order-male", {order: "popular_male_d"});
this.set_item(container, "order-female", {order: "popular_female_d"});
let set_search_mode = (container, type, mode) =>
{
var link = container.querySelector("[data-type='" + type + "']");
if(link == null)
{
console.warn("Couldn't find button with selector", type);
return;
}
let current_mode = this.get_url_search_mode();
let button_is_selected = current_mode == mode;
helpers.set_class(link, "selected", button_is_selected);
// Adjust the URL for this button.
let url = this.set_url_search_mode(this.url, mode);
link.href = url.toString();
};
set_search_mode(container, "search-type-all", "all");
set_search_mode(container, "search-type-illust", "illust");
set_search_mode(container, "search-type-manga", "manga");
set_search_mode(container, "search-type-ugoira", "ugoira");
this.set_item(container, "search-all", {s_mode: null}, {s_mode: "s_tag"});
this.set_item(container, "search-exact", {s_mode: "s_tag_full"});
this.set_item(container, "search-text", {s_mode: "s_tc"});
this.set_item(container, "res-all", {wlt: null, hlt: null, wgt: null, hgt: null});
this.set_item(container, "res-high", {wlt: 3000, hlt: 3000, wgt: null, hgt: null});
this.set_item(container, "res-medium", {wlt: 1000, hlt: 1000, wgt: 2999, hgt: 2999});
this.set_item(container, "res-low", {wlt: null, hlt: null, wgt: 999, hgt: 999});
this.set_item(container, "aspect-ratio-all", {ratio: null});
this.set_item(container, "aspect-ratio-landscape", {ratio: "0.5"});
this.set_item(container, "aspect-ratio-portrait", {ratio: "-0.5"});
this.set_item(container, "aspect-ratio-square", {ratio: "0"});
this.set_item(container, "bookmarks-all", {blt: null, bgt: null});
this.set_item(container, "bookmarks-5000", {blt: 5000, bgt: null});
this.set_item(container, "bookmarks-2500", {blt: 2500, bgt: null});
this.set_item(container, "bookmarks-1000", {blt: 1000, bgt: null});
this.set_item(container, "bookmarks-500", {blt: 500, bgt: null});
this.set_item(container, "bookmarks-250", {blt: 250, bgt: null});
this.set_item(container, "bookmarks-100", {blt: 100, bgt: null});
// The time filter is a range, but I'm not sure what time zone it filters in
// (presumably either JST or UTC). There's also only a date and not a time,
// which means you can't actually filter "today", since there's no way to specify
// which "today" you mean. So, we offer filtering starting at "this week",
// and you can just use the default date sort if you want to see new posts.
// For "this week", we set the end date a day in the future to make sure we
// don't filter out posts today.
this.set_item(container, "time-all", {scd: null, ecd: null});
var format_date = (date) =>
{
var f = (date.getYear() + 1900).toFixed();
return (date.getYear() + 1900).toFixed().padStart(2, "0") + "-" +
(date.getMonth() + 1).toFixed().padStart(2, "0") + "-" +
date.getDate().toFixed().padStart(2, "0");
};
var set_date_filter = (name, start, end) =>
{
var start_date = format_date(start);
var end_date = format_date(end);
this.set_item(container, name, {scd: start_date, ecd: end_date});
};
var tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1);
var last_week = new Date(); last_week.setDate(last_week.getDate() - 7);
var last_month = new Date(); last_month.setMonth(last_month.getMonth() - 1);
var last_year = new Date(); last_year.setFullYear(last_year.getFullYear() - 1);
set_date_filter("time-week", last_week, tomorrow);
set_date_filter("time-month", last_month, tomorrow);
set_date_filter("time-year", last_year, tomorrow);
for(var years_ago = 1; years_ago <= 7; ++years_ago)
{
var start_year = new Date(); start_year.setFullYear(start_year.getFullYear() - years_ago - 1);
var end_year = new Date(); end_year.setFullYear(end_year.getFullYear() - years_ago);
set_date_filter("time-years-ago-" + years_ago, start_year, end_year);
}
this.set_active_popup_highlight(container, [".ages-box", ".popularity-box", ".type-box", ".search-mode-box", ".size-box", ".aspect-ratio-box", ".bookmarks-box", ".time-box", ".member-tags-box"]);
// The "reset search" button removes everything in the query except search terms, and resets
// the search type.
var box = container.querySelector(".reset-search");
let url = new URL(this.url);
let tag = helpers._get_search_tags_from_url(url);
url.search = "";
if(tag == null)
url.pathname = "/tags";
else
url.pathname = "/tags/" + encodeURIComponent(tag) + "/artworks";
box.href = url;
}
};
ppixiv.data_sources.follows = class extends data_source
{
get name() { return "following"; }
get search_mode() { return "users"; }
constructor(url)
{
super(url);
this.follow_tags = null;
}
get supports_start_page()
{
return true;
}
get viewing_user_id()
{
if(helpers.get_path_part(this.url, 0) == "users")
{
// New URLs (/users/13245/follows)
return helpers.get_path_part(this.url, 1);
}
var query_args = this.url.searchParams;
let user_id = query_args.get("id");
if(user_id == null)
return window.global_data.user_id;
return user_id;
};
async load_page_internal(page)
{
// Make sure the user info is loaded. This should normally be preloaded by globalInitData
// in main.js, and this won't make a request.
this.user_info = await image_data.singleton().get_user_info_full(this.viewing_user_id);
// Update to refresh our page title, which uses user_info.
this.call_update_listeners();
var query_args = this.url.searchParams;
var rest = query_args.get("rest") || "show";
var url = "/ajax/user/" + this.viewing_user_id + "/following";
let args = {
offset: this.estimated_items_per_page*(page-1),
limit: this.estimated_items_per_page,
rest: rest,
};
if(query_args.get("tag"))
args.tag = query_args.get("tag");
let result = await helpers.get_request(url, args);
// Store following tags.
this.follow_tags = result.body.followUserTags;
// Make a list of the first illustration for each user.
var illusts = [];
for(let followed_user of result.body.users)
{
if(followed_user == null)
continue;
// Register this as quick user data, for use in thumbnails.
thumbnail_data.singleton().add_quick_user_data(followed_user, "following");
// XXX: user:user_id
if(!followed_user.illusts.length)
{
console.log("Can't show followed user that has no posts:", followed_user.userId);
continue;
}
let illust = followed_user.illusts[0];
illusts.push(illust);
// We'll register this with thumbnail_data below. These results don't have profileImageUrl
// and only put it in the enclosing user, so copy it over.
illust.profileImageUrl = followed_user.profileImageUrl;
}
var illust_ids = [];
for(let illust of illusts)
illust_ids.push("user:" + illust.userId);
// This request returns all of the thumbnail data we need. Forward it to
// thumbnail_data so we don't need to look it up.
thumbnail_data.singleton().loaded_thumbnail_info(illusts, "normal");
// Register the new page of data.
this.add_page(page, illust_ids);
}
refresh_thumbnail_ui(container, thumbnail_view)
{
thumbnail_view.avatar_widget.visible = true;
thumbnail_view.avatar_widget.set_user_id(this.viewing_user_id);
// The public/private button only makes sense when viewing your own follows.
var public_private_button_container = container.querySelector(".follows-public-private");
public_private_button_container.hidden = !this.viewing_self;
this.set_item(container, "public-follows", {rest: "show"}, {rest: "show"});
this.set_item(container, "private-follows", {rest: "hide"}, {rest: "show"});
var tag_list = container.querySelector(".follow-tag-list");
helpers.remove_elements(tag_list);
// Refresh the bookmark tag list. Remove the page number from these buttons.
let current_url = new URL(this.url);
current_url.searchParams.delete("p");
let current_query = current_url.searchParams.toString();
var add_tag_link = (tag) =>
{
var a = document.createElement("a");
a.classList.add("box-link");
a.classList.add("following-tag");
a.innerText = tag;
let url = new URL(this.url);
url.searchParams.delete("p");
if(tag == "Uncategorized")
url.searchParams.set("untagged", 1);
else
url.searchParams.delete("untagged", 1);
if(tag != "All")
url.searchParams.set("tag", tag);
else
url.searchParams.delete("tag");
a.href = url.toString();
if(url.searchParams.toString() == current_query)
a.classList.add("selected");
tag_list.appendChild(a);
};
add_tag_link("All");
for(var tag of this.follow_tags || [])
add_tag_link(tag);
}
get viewing_self()
{
return this.viewing_user_id == window.global_data.user_id;
}
get page_title()
{
if(!this.viewing_self)
{
if(this.user_info)
return this.user_info.name + "'s Follows";
return "User's follows";
}
var query_args = this.url.searchParams;
var private_follows = query_args.get("rest") == "hide";
return private_follows? "Private follows":"Followed users";
};
get_displaying_text()
{
if(!this.viewing_self)
{
if(this.user_info)
return this.user_info.name + "'s followed users";
return "User's followed users";
}
var query_args = this.url.searchParams;
var private_follows = query_args.get("rest") == "hide";
return private_follows? "Private follows":"Followed users";
};
}
// bookmark_detail.php
//
// This lists the users who publically bookmarked an illustration, linking to each users' bookmarks.
ppixiv.data_sources.related_favorites = class extends data_source_from_page
{
get name() { return "illust-bookmarks"; }
get search_mode() { return "users"; }
constructor(url)
{
super(url);
this.illust_info = null;
}
async load_page_internal(page)
{
// Get info for the illustration we're displaying bookmarks for.
var query_args = this.url.searchParams;
var illust_id = query_args.get("illust_id");
this.illust_info = await image_data.singleton().get_image_info(illust_id);
return super.load_page_internal(page);
}
// Parse the loaded document and return the illust_ids.
parse_document(doc)
{
var ids = [];
for(var element of doc.querySelectorAll("li.bookmark-item a[data-user_id]"))
{
// Register this as quick user data, for use in thumbnails.
thumbnail_data.singleton().add_quick_user_data({
user_id: element.dataset.user_id,
user_name: element.dataset.user_name,
// This page gives links to very low-res avatars. Replace them with the high-res ones
// that newer pages give.
//
// These links might be annoying animated GIFs, but we don't bother killing them here
// like we do for the followed page since this isn't used very much.
profile_img: element.dataset.profile_img.replace("_50.", "_170."),
}, "users_bookmarking_illust");
// The bookmarks: URL type will generate links to this user's bookmarks.
ids.push("bookmarks:" + element.dataset.user_id);
}
return ids;
}
refresh_thumbnail_ui(container, thumbnail_view)
{
// Set the source image.
var source_link = container.querySelector(".image-for-suggestions");
source_link.hidden = this.illust_info == null;
if(this.illust_info)
{
source_link.href = "/artworks/" + this.illust_info.illustId + "#ppixiv";
var img = source_link.querySelector(".image-for-suggestions > img");
img.src = this.illust_info.urls.thumb;
}
}
get page_title()
{
return "Similar Bookmarks";
};
get_displaying_text()
{
if(this.illust_info)
return "Users who bookmarked " + this.illust_info.illustTitle;
else
return "Users who bookmarked image";
};
}
ppixiv.data_sources.search_users = class extends data_source_from_page
{
get name() { return "search-users"; }
get search_mode() { return "users"; }
parse_document(doc)
{
var illust_ids = [];
for(let item of doc.querySelectorAll(".user-recommendation-items .user-recommendation-item"))
{
let username = item.querySelector(".title").innerText;
let user_id = item.querySelector(".follow").dataset.id;
let profile_image = item.querySelector("._user-icon").dataset.src;
thumbnail_data.singleton().add_quick_user_data({
user_id: user_id,
user_name: username,
profile_img: profile_image,
}, "user_search");
illust_ids.push("user:" + user_id);
}
return illust_ids;
}
initial_refresh_thumbnail_ui(container, view)
{
let search = this.url.searchParams.get("nick");
container.querySelector(".search-users").value = search;
}
get page_title()
{
let search = this.url.searchParams.get("nick");
if(search)
return "Search users: " + search;
else
return "Search users";
};
get_displaying_text()
{
return this.page_title;
};
}
// /history.php - Recent history
//
// This uses our own history and not Pixiv's history.
ppixiv.data_sources.recent = class extends data_source
{
get name() { return "recent"; }
// Implement data_source_fake_pagination:
async load_page_internal(page)
{
// Read illust_ids once and paginate them so we don't return them all at once.
if(this.illust_ids == null)
{
let illust_ids = await ppixiv.recently_seen_illusts.get().get_recent_illust_ids();
this.pages = paginate_illust_ids(illust_ids, this.estimated_items_per_page);
}
// Register this page.
let illust_ids = this.pages[page-1] || [];
let found_illust_ids = [];
// Get thumbnail data for this page. Some thumbnail data might be missing if it
// expired before this page was viewed. Don't add illust IDs that we don't have
// thumbnail data for.
let thumbs = await ppixiv.recently_seen_illusts.get().get_thumbnail_info(illust_ids);
thumbnail_data.singleton().loaded_thumbnail_info(thumbs, "normal");
for(let thumb of thumbs)
found_illust_ids.push(thumb.id);
this.add_page(page, found_illust_ids);
};
get page_title() { return "Recent"; }
get_displaying_text() { return "Recent History"; }
// This data source is transient, so it's recreated each time the user navigates to it.
get transient() { return true; }
refresh_thumbnail_ui(container)
{
// Set .selected on the current mode.
let current_mode = this.url.searchParams.get("mode") || "all";
helpers.set_class(container.querySelector(".box-link[data-type=all]"), "selected", current_mode == "all");
helpers.set_class(container.querySelector(".box-link[data-type=safe]"), "selected", current_mode == "safe");
helpers.set_class(container.querySelector(".box-link[data-type=r18]"), "selected", current_mode == "r18");
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/data_sources.js
`;
ppixiv.resources["src/encode_mkv.js"] = `"use strict";
// This is a simple hack to piece together an MJPEG MKV from a bunch of JPEGs.
ppixiv.encode_mkv = (function() {
var encode_length = function(value)
{
// Encode a 40-bit EBML int. This lets us encode 32-bit ints with no extra logic.
return struct(">BI").pack(0x08, value);
};
var header_int = function(container, identifier, value)
{
container.push(new Uint8Array(identifier));
var data = struct(">II").pack(0, value);
var size = data.byteLength;
container.push(encode_length(size));
container.push(data);
};
var header_float = function(container, identifier, value)
{
container.push(new Uint8Array(identifier));
var data = struct(">f").pack(value);
var size = data.byteLength;
container.push(encode_length(size));
container.push(data);
};
var header_data = function(container, identifier, data)
{
container.push(new Uint8Array(identifier));
container.push(encode_length(data.byteLength));
container.push(data);
};
// Return the total size of an array of ArrayBuffers.
var total_size = function(array)
{
var size = 0;
for(var idx = 0; idx < array.length; ++idx)
{
var item = array[idx];
size += item.byteLength;
}
return size;
};
var append_array = function(a1, a2)
{
var result = new Uint8Array(a1.byteLength + a2.byteLength);
result.set(new Uint8Array(a1));
result.set(new Uint8Array(a2), a1.byteLength);
return result;
};
// Create an EBML block from an identifier and a list of Uint8Array parts. Return a
// single Uint8Array.
var create_data_block = function(identifier, parts)
{
var identifier = new Uint8Array(identifier);
var data_size = total_size(parts);
var encoded_data_size = encode_length(data_size);
var result = new Uint8Array(identifier.byteLength + encoded_data_size.byteLength + data_size);
var pos = 0;
result.set(new Uint8Array(identifier), pos);
pos += identifier.byteLength;
result.set(new Uint8Array(encoded_data_size), pos);
pos += encoded_data_size.byteLength;
for(var i = 0; i < parts.length; ++i)
{
var part = parts[i];
result.set(new Uint8Array(part), pos);
pos += part.byteLength;
}
return result;
};
// EBML data types
var ebml_header = function()
{
var parts = [];
header_int(parts, [0x42, 0x86], 1); // EBMLVersion
header_int(parts, [0x42, 0xF7], 1); // EBMLReadVersion
header_int(parts, [0x42, 0xF2], 4); // EBMLMaxIDLength
header_int(parts, [0x42, 0xF3], 8); // EBMLMaxSizeLength
header_data(parts, [0x42, 0x82], new Uint8Array([0x6D, 0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61])); // DocType ("matroska")
header_int(parts, [0x42, 0x87], 4); // DocTypeVersion
header_int(parts, [0x42, 0x85], 2); // DocTypeReadVersion
return create_data_block([0x1A, 0x45, 0xDF, 0xA3], parts); // EBML
};
var ebml_info = function(duration)
{
var parts = [];
header_int(parts, [0x2A, 0xD7, 0xB1], 1000000); // TimecodeScale
header_data(parts, [0x4D, 0x80], new Uint8Array([120])); // MuxingApp ("x") (this shouldn't be mandatory)
header_data(parts, [0x57, 0x41], new Uint8Array([120])); // WritingApp ("x") (this shouldn't be mandatory)
header_float(parts, [0x44, 0x89], duration * 1000); // Duration (why is this a float?)
return create_data_block([0x15, 0x49, 0xA9, 0x66], parts); // Info
};
var ebml_track_entry_video = function(width, height)
{
var parts = [];
header_int(parts, [0xB0], width); // PixelWidth
header_int(parts, [0xBA], height); // PixelHeight
return create_data_block([0xE0], parts); // Video
};
var ebml_track_entry = function(width, height)
{
var parts = [];
header_int(parts, [0xD7], 1); // TrackNumber
header_int(parts, [0x73, 0xC5], 1); // TrackUID
header_int(parts, [0x83], 1); // TrackType (video)
header_int(parts, [0x9C], 0); // FlagLacing
header_int(parts, [0x23, 0xE3, 0x83], 33333333); // DefaultDuration (overridden per frame)
header_data(parts, [0x86], new Uint8Array([0x56, 0x5f, 0x4d, 0x4a, 0x50, 0x45, 0x47])); // CodecID ("V_MJPEG")
parts.push(ebml_track_entry_video(width, height));
return create_data_block([0xAE], parts); // TrackEntry
};
var ebml_tracks = function(width, height)
{
var parts = [];
parts.push(ebml_track_entry(width, height));
return create_data_block([0x16, 0x54, 0xAE, 0x6B], parts); // Tracks
};
var ebml_simpleblock = function(frame_data)
{
// We should be able to use encode_length(1), but for some reason, while everything else
// handles our non-optimal-length ints just fine, this field doesn't. Manually encode it
// instead.
var result = new Uint8Array([
0x81, // track number 1 (EBML encoded)
0, 0, // timecode relative to cluster
0x80, // flags (keyframe)
]);
result = append_array(result, frame_data);
return result;
};
var ebml_cluster = function(frame_data, frame_time)
{
var parts = [];
header_int(parts, [0xE7], Math.round(frame_time * 1000)); // Timecode
header_data(parts, [0xA3], ebml_simpleblock(frame_data)); // SimpleBlock
return create_data_block([0x1F, 0x43, 0xB6, 0x75], parts); // Cluster
};
var ebml_cue_track_positions = function(file_position)
{
var parts = [];
header_int(parts, [0xF7], 1); // CueTrack
header_int(parts, [0xF1], file_position); // CueClusterPosition
return create_data_block([0xB7], parts); // CueTrackPositions
};
var ebml_cue_point = function(frame_time, file_position)
{
var parts = [];
header_int(parts, [0xB3], Math.round(frame_time * 1000)); // CueTime
parts.push(ebml_cue_track_positions(file_position));
return create_data_block([0xBB], parts); // CuePoint
};
var ebml_cues = function(frame_times, frame_file_positions)
{
var parts = [];
for(var frame = 0; frame < frame_file_positions.length; ++frame)
{
var frame_time = frame_times[frame];
var file_position = frame_file_positions[frame];
parts.push(ebml_cue_point(frame_time, file_position));
}
return create_data_block([0x1C, 0x53, 0xBB, 0x6B], parts); // Cues
};
var ebml_segment = function(parts)
{
return create_data_block([0x18, 0x53, 0x80, 0x67], parts); // Segment
};
// API:
// We don't decode the JPEG frames while we do this, so the resolution is supplied here.
class encode_mkv
{
constructor(width, height)
{
this.width = width;
this.height = height;
this.frames = [];
}
add(jpeg_data, frame_duration_ms)
{
this.frames.push({
data: jpeg_data,
duration: frame_duration_ms,
});
};
build()
{
// Sum the duration of the video.
var duration = 0;
for(var frame = 0; frame < this.frames.length; ++frame)
{
var data = this.frames[frame].data;
var ms = this.frames[frame].duration;
duration += ms / 1000.0;
}
var header_parts = ebml_header();
var parts = [];
parts.push(ebml_info(duration));
parts.push(ebml_tracks(this.width, this.height));
// current_pos is the relative position from the start of the segment (after the ID and
// size bytes) to the beginning of the cluster.
var current_pos = 0;
for(var part of parts)
current_pos += part.byteLength;
// Create each frame as its own cluster, and keep track of the file position of each.
var frame_file_positions = [];
var frame_file_times = [];
var frame_time = 0;
for(var frame = 0; frame < this.frames.length; ++frame)
{
var data = this.frames[frame].data;
var ms = this.frames[frame].duration;
var cluster = ebml_cluster(data, frame_time);
parts.push(cluster);
frame_file_positions.push(current_pos);
frame_file_times.push(frame_time);
frame_time += ms / 1000.0;
current_pos += cluster.byteLength;
};
// Add the frame index.
parts.push(ebml_cues(frame_file_times, frame_file_positions));
// Create an EBMLSegment containing all of the parts (excluding the header).
var segment = ebml_segment(parts);
// Return a blob containing the final data.
var file = [];
file = file.concat(header_parts);
file = file.concat(segment);
return new Blob(file);
};
};
return encode_mkv;
})();
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/encode_mkv.js
`;
ppixiv.resources["src/hide_mouse_cursor_on_idle.js"] = `"use strict";
// A singleton that keeps track of whether the mouse has moved recently.
//
// Dispatch "mouseactive" on window when the mouse has moved recently and
// "mouseinactive" when it hasn't.
ppixiv.track_mouse_movement = class
{
constructor()
{
track_mouse_movement._singleton = this;
this.idle = this.idle.bind(this);
this.onmousemove = this.onmousemove.bind(this);
this.force_hidden_until = null;
this.set_mouse_anchor_timeout = -1;
this.last_mouse_pos = null;
window.addEventListener("mousemove", this.onmousemove, { capture: true });
}
static _singleton = null;
static get singleton() { return track_mouse_movement._singleton; }
// True if the mouse is active. This corresponds to the mouseactive and mouseinactive
// events.
get active() { return _this; }
// Briefly pretend that the mouse is inactive.
//
// This is done when releasing a zoom to prevent spuriously showing the mouse cursor.
simulate_inactivity()
{
this.force_hidden_until = Date.now() + 150;
this.idle();
}
onmousemove(e)
{
let mouse_pos = [e.screenX, e.screenY];
this.last_mouse_pos = mouse_pos;
if(!this.anchor_pos)
this.anchor_pos = this.last_mouse_pos;
// Cleare the anchor_pos timeout when the mouse moves.
this.clear_mouse_anchor_timeout();
// If we're forcing the cursor inactive for a while, stop.
if(this.force_hidden_until && this.force_hidden_until > Date.now())
return;
// Show the cursor if the mouse has moved far enough from the current anchor_pos.
let distance_moved = helpers.distance(this.anchor_pos, mouse_pos);
if(distance_moved > 10)
{
this.mark_mouse_active();
return;
}
// If we see mouse movement that isn't enough to cause us to display the cursor
// and we don't see more movement for a while, reset anchor_pos so we discard
// the movement we saw.
this.set_mouse_anchor_timeout = setTimeout(() => {
this.set_mouse_anchor_timeout = -1;
this.anchor_pos = this.last_mouse_pos;
}, 500);
}
// Remove the set_mouse_anchor_timeout timeout, if any.
clear_mouse_anchor_timeout()
{
if(this.set_mouse_anchor_timeout == -1)
return;
clearTimeout(this.set_mouse_anchor_timeout);
this.set_mouse_anchor_timeout = -1;
}
remove_timer()
{
if(!this.timer)
return;
clearTimeout(this.timer);
this.timer = null;
}
// The mouse has been active recently. Send mouseactive if the state is changing,
// and schedule the next time it'll become inactive.
mark_mouse_active()
{
// When showing the cursor, snap the mouse movement anchor to the last seen position
// and remove any anchor_pos timeout.
this.anchor_pos = this.last_mouse_pos;
this.clear_mouse_anchor_timeout();
this.remove_timer();
this.timer = setTimeout(this.idle, 500);
if(!this._active)
{
this._active = true;
window.dispatchEvent(new Event("mouseactive"));
}
}
// The timer has expired (or was forced to expire).
idle()
{
this.remove_timer();
if(this._active)
{
window.dispatchEvent(new Event("mouseinactive"));
this._active = false;
}
}
}
// Hide the mouse cursor when it hasn't moved briefly, to get it out of the way.
// This only hides the cursor over element.
ppixiv.hide_mouse_cursor_on_idle = class
{
constructor(element)
{
hide_mouse_cursor_on_idle.add_style();
this.track = new track_mouse_movement();
this.show_cursor = this.show_cursor.bind(this);
this.hide_cursor = this.hide_cursor.bind(this);
this.element = element;
this.cursor_hidden = false;
window.addEventListener("mouseactive", this.show_cursor);
window.addEventListener("mouseinactive", this.hide_cursor);
settings.register_change_callback("no-hide-cursor", hide_mouse_cursor_on_idle.update_from_settings);
hide_mouse_cursor_on_idle.update_from_settings();
}
static add_style()
{
if(hide_mouse_cursor_on_idle.global_style)
return;
// Create the style to hide the mouse cursor. This hides the mouse cursor on .hide-cursor,
// and forces everything underneath it to inherit it. This prevents things further down
// that set their own cursors from unhiding it.
//
// This also works around a Chrome bug: if the cursor is hidden, and we show the cursor while
// simultaneously animating an element to be visible over it, it doesn't recognize
// hovers over the element until the animation completes or the mouse moves. It
// seems to be incorrectly optimizing out hover checks when the mouse is hidden.
// Work around this by hiding the cursor with an empty image instead of cursor: none,
// so it doesn't know that the cursor isn't visible.
//
// This is set as a separate style, so we can disable it selectively. This allows us to
// globally disable mouse hiding. This used to be done by setting a class on body, but
// that's slower and can cause animation hitches.
let style = helpers.add_style("hide-cursor", \`
.hide-cursor {
cursor: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="), none !important;
}
.hide-cursor * { cursor: inherit !important; }
\`);
hide_mouse_cursor_on_idle.global_style = style;
}
static update_from_settings()
{
// If no-hide-cursor is true, disable the style that hides the cursor. We track cursor
// hiding and set the local hide-cursor style even if cursor hiding is disabled, so
// other UI can use it, like video seek bars.
hide_mouse_cursor_on_idle.global_style.disabled = settings.get("no-hide-cursor");
}
// Temporarily disable hiding all mouse cursors.
static enable_all()
{
// Just let update_from_settings readding the enable-cursor-hiding class if needed.
this.update_from_settings();
}
static disable_all()
{
// Just disable the style, so we stop hiding the mouse. We don't just unset the hide-cursor
// class, so this only stops hiding the mouse cursor and doesn't cause other UI like seek
// bars to be displayed.
hide_mouse_cursor_on_idle.global_style.disabled = true;
}
show_cursor(e)
{
this.cursor_hidden = false;
this.refresh_hide_cursor();
}
hide_cursor(e)
{
this.cursor_hidden = true;
this.refresh_hide_cursor();
}
refresh_hide_cursor()
{
let hidden = this.cursor_hidden;
helpers.set_class(this.element, "hide-cursor", hidden);
helpers.set_class(this.element, "show-cursor", !hidden);
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/hide_mouse_cursor_on_idle.js
`;
ppixiv.resources["src/image_data.js"] = `"use strict";
// This handles fetching and caching image data and associated user data.
//
// We always load the user data for an illustration if it's not already loaded. We also
// load ugoira_metadata. This way, we can access all the info we need for an image in
// one place, without doing multi-phase loads elsewhere.
ppixiv.image_data = class
{
constructor()
{
this.loaded_user_info = this.loaded_user_info.bind(this);
this.illust_modified_callbacks = new callback_list();
this.user_modified_callbacks = new callback_list();
// Cached data:
this.image_data = { };
this.user_data = { };
this.bookmarked_image_tags = { };
this.recent_likes = { }
// Negative cache to remember illusts that don't exist, so we don't try to
// load them repeatedly:
this.nonexistant_illist_ids = { };
this.nonexistant_user_ids = { }; // XXX
this.illust_loads = {};
this.user_info_loads = {};
};
// Return the singleton, creating it if needed.
static singleton()
{
if(image_data._singleton == null)
image_data._singleton = new image_data();
return image_data._singleton;
};
// Call all illust_modified callbacks.
call_user_modified_callbacks(user_id)
{
console.log("User modified:", user_id);
this.user_modified_callbacks.call(user_id);
}
call_illust_modified_callbacks(illust_id)
{
this.illust_modified_callbacks.call(illust_id);
}
// Get image data asynchronously.
//
// await get_image_info(12345);
//
// If illust_id is a video, we'll also download the metadata before returning it, and store
// it as image_data.ugoiraMetadata.
get_image_info(illust_id)
{
if(illust_id == null)
return null;
// Stop if we know this illust doesn't exist.
if(illust_id in this.nonexistant_illist_ids)
return null;
// If we already have the image data, just return it.
if(this.image_data[illust_id] != null)
{
return new Promise(resolve => {
resolve(this.image_data[illust_id]);
});
}
// If there's already a load in progress, just return it.
if(this.illust_loads[illust_id] != null)
return this.illust_loads[illust_id];
var load_promise = this.load_image_info(illust_id);
this._started_loading_image_info(illust_id, load_promise);
return load_promise;
}
_started_loading_image_info(illust_id, load_promise)
{
this.illust_loads[illust_id] = load_promise;
this.illust_loads[illust_id].then(() => {
delete this.illust_loads[illust_id];
});
}
// Like get_image_info, but return the result immediately.
//
// If the image info isn't loaded, don't start a request and just return null.
get_image_info_sync(illust_id)
{
return this.image_data[illust_id];
}
// Load illust_id and all data that it depends on.
//
// If we already have the image data (not necessarily the rest, like ugoira_metadata),
// it can be supplied with illust_data.
//
// If load_user_info is true, we'll attempt to load user info in parallel. It still
// needs to be requested with get_user_info(), but loading it here can allow requesting
// it sooner.
async load_image_info(illust_id, illust_data, { load_user_info=false }={})
{
// See if we already have data for this image. If we do, stop. We always load
// everything we need if we load anything at all.
if(this.image_data[illust_id] != null)
return;
// We need the illust data, user data, and ugoira metadata (for illustType 2). (We could
// load manga data too, but we currently let the manga view do that.) We need to know the
// user ID and illust type to start those loads.
console.log("Fetching", illust_id);
var user_info_promise = null;
var manga_promise = null;
var ugoira_promise = null;
// Given a user ID and/or an illust_type (or null if either isn't known yet), start any
// fetches we can.
var start_loading = (user_id, illust_type, page_count) => {
// If we know the user ID and haven't started loading user info yet, start it.
if(load_user_info && user_info_promise == null && user_id != null)
user_info_promise = this.get_user_info(user_id);
// If we know the illust type and haven't started loading other data yet, start them.
if(page_count != null && page_count > 1 && manga_promise == null && (illust_data == null || illust_data.mangaPages == null))
manga_promise = helpers.get_request("/ajax/illust/" + illust_id + "/pages", {});
if(illust_type == 2 && ugoira_promise == null && (illust_data == null || illust_data.ugoiraMetadata == null))
ugoira_promise = helpers.get_request("/ajax/illust/" + illust_id + "/ugoira_meta");
};
// If we have thumbnail info, it tells us the user ID. This lets us start loading
// user info without waiting for the illustration data to finish loading first.
// Don't fetch thumbnail info if it's not already loaded.
var thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id);
if(thumbnail_info != null)
start_loading(thumbnail_info.userId, thumbnail_info.illustType, thumbnail_info.pageCount);
// If we don't have illust data, block while it loads.
if(illust_data == null)
{
var illust_result_promise = helpers.get_request("/ajax/illust/" + illust_id, {});
var illust_result = await illust_result_promise;
if(illust_result == null || illust_result.error)
{
let message = illust_result?.message || "Error loading illustration";
console.log(\`Error loading illust \${illust_id}; \${message}\`);
this.nonexistant_illist_ids[illust_id] = message;
return null;
}
illust_data = illust_result.body;
}
tag_translations.get().add_translations(illust_data.tags.tags);
// Now that we have illust data, load anything we weren't able to load before.
start_loading(illust_data.userId, illust_data.illustType, illust_data.pageCount);
// If we're loading image info, we're almost definitely going to load the avatar, so
// start preloading it now.
let user_info = await user_info_promise;
if(user_info)
helpers.preload_images([user_info.imageBig]);
if(manga_promise != null)
{
var manga_info = await manga_promise;
illust_data.mangaPages = manga_info.body;
}
if(ugoira_promise != null)
{
var ugoira_result = await ugoira_promise;
illust_data.ugoiraMetadata = ugoira_result.body;
}
// If this is a single-page image, create a dummy single-entry mangaPages array. This lets
// us treat all images the same.
if(illust_data.pageCount == 1)
{
illust_data.mangaPages = [{
width: illust_data.width,
height: illust_data.height,
// Rather than just referencing illust_Data.urls, copy just the image keys that
// exist in the regular mangaPages list (no thumbnails).
urls: {
original: illust_data.urls.original,
regular: illust_data.urls.regular,
small: illust_data.urls.small,
}
}];
}
// Store the image data.
this.image_data[illust_id] = illust_data;
return illust_data;
}
// If get_image_info or get_user_info returned null, return the error message.
get_illust_load_error(illust_id) { return this.nonexistant_illist_ids[illust_id]; }
get_user_load_error(user_id) { return this.nonexistant_user_ids[illust_id]; }
// The user request can either return a small subset of data (just the username,
// profile image URL, etc.), or a larger set with a webpage URL, Twitter, etc.
// User preloads often only have the smaller set, and we want to use the preload
// data whenever possible.
//
// get_user_info requests the smaller set of data, and get_user_info_full requests
// the full data.
//
// Note that get_user_info will return the full data if we have it already.
async get_user_info_full(user_id)
{
return await this._get_user_info(user_id, true);
}
async get_user_info(user_id)
{
return await this._get_user_info(user_id, false);
}
get_user_info_sync(user_id)
{
return this.user_data[user_id];
}
// Load user_id if needed.
//
// If load_full_data is false, it means the caller only needs partial data, and we
// won't send a request if we already have that, but if we do end up loading the
// user we'll always load full data.
//
// Some sources only give us partial data, which only has a subset of keys. See
// _check_user_data for the keys available with partial and full data.
_get_user_info(user_id, load_full_data)
{
if(user_id == null)
return null;
// Stop if we know this user doesn't exist.
if(user_id in this.nonexistant_user_ids)
return null;
// If we already have the user info for this illustration (and it's full data, if
// requested), we're done.
if(this.user_data[user_id] != null)
{
// user_info.partial is 1 if it's the full data (this is backwards). If we need
// full data and we only have partial data, we still need to request data.
if(!load_full_data || this.user_data[user_id].partial)
{
return new Promise(resolve => {
resolve(this.user_data[user_id]);
});
}
}
// If there's already a load in progress, just return it.
if(this.user_info_loads[user_id] != null)
return this.user_info_loads[user_id];
this.user_info_loads[user_id] = this.load_user_info(user_id);
this.user_info_loads[user_id].then(() => {
delete this.user_info_loads[user_id];
});
return this.user_info_loads[user_id];
};
async load_user_info(user_id)
{
// console.log("Fetch user", user_id);
let result = await helpers.get_request("/ajax/user/" + user_id, {full:1});
if(result == null || result.error)
{
let message = result?.message || "Error loading user";
console.log(\`Error loading user \${user_id}; \${message}\`);
this.nonexistant_user_ids[user_id] = message;
return null;
}
return this.loaded_user_info(result);
}
_check_user_data(user_data)
{
// Make sure that the data contains all of the keys we expect, so we catch any unexpected
// missing data early. Discard keys that we don't use, to make sure we update this if we
// make use of new keys. This makes sure that the user data keys are always consistent.
let full_keys = [
'userId',
// 'background',
// 'image',
'imageBig',
// 'isBlocking',
'isFollowed',
'isMypixiv',
'name',
'partial',
'social',
'commentHtml',
// 'premium',
// 'sketchLiveId',
// 'sketchLives',
];
let partial_keys = [
'userId',
'isFollowed',
'name',
'imageBig',
'partial',
];
// partial is 0 if this is partial user data and 1 if it's full data (this is backwards).
let expected_keys = user_data.partial? full_keys:partial_keys;
var thumbnail_info_map = this.thumbnail_info_map_illust_list;
var remapped_user_data = { };
for(let key of expected_keys)
{
if(!(key in user_data))
{
console.warn("User info is missing key:", key);
continue;
}
remapped_user_data[key] = user_data[key];
}
return remapped_user_data;
}
loaded_user_info(user_result)
{
if(user_result.error)
return;
var user_data = user_result.body;
user_data = this._check_user_data(user_data);
var user_id = user_data.userId;
// console.log("Got user", user_id);
// Store the user data.
if(this.user_data[user_id] == null)
this.user_data[user_id] = user_data;
else
{
// If we already have an object for this user, we're probably replacing partial user data
// with full user data. Don't replace the user_data object itself, since widgets will have
// a reference to the old one which will become stale. Just replace the data inside the
// object.
var old_user_data = this.user_data[user_id];
for(var key of Object.keys(old_user_data))
delete old_user_data[key];
for(var key of Object.keys(user_data))
old_user_data[key] = user_data[key];
}
return user_data;
}
// Add image and user data to the cache that we received from other sources. Note that if
// we have any fetches in the air already, we'll leave them running.
add_illust_data(illust_data)
{
var load_promise = this.load_image_info(illust_data.illustId, illust_data);
this._started_loading_image_info(illust_data.illustId, load_promise);
}
add_user_data(user_data)
{
this.loaded_user_info({
body: user_data,
});
}
// Load bookmark tags.
//
// There's no visible API to do this, so we have to scrape the bookmark_add page. I wish
// they'd just include this in bookmarkData. Since this takes an extra request, we should
// only load this if the user is viewing/editing bookmark tags.
get_bookmark_details(illust_info)
{
var illust_id = illust_info.illustId;
if(this.bookmark_details[illust_id] == null)
this.bookmark_details[illust_id] = this.load_bookmark_details(illust_info);
return this.bookmark_details[illust_id];
}
async load_bookmark_details(illust_id)
{
// Stop if this is already loaded.
if(this.bookmarked_image_tags[illust_id])
return this.bookmarked_image_tags[illust_id];
let bookmark_page = await helpers.load_data_in_iframe("/bookmark_add.php?type=illust&illust_id=" + illust_id);
let tags = bookmark_page.querySelector(".bookmark-detail-unit form input[name='tag']").value;
tags = tags.split(" ");
tags = tags.filter((value) => { return value != ""; });
this.bookmarked_image_tags[illust_id] = tags;
return this.bookmarked_image_tags[illust_id];
}
// Replace our cache of bookmark tags for an image. This is used after updating
// a bookmark.
update_cached_bookmark_image_tags(illust_id, tags)
{
if(tags == null)
delete this.bookmarked_image_tags[illust_id];
else
this.bookmarked_image_tags[illust_id] = tags;
this.call_illust_modified_callbacks(illust_id);
}
thumbnail_info_early_illust_data_keys = {
"id": "id",
"illustType": "illustType",
"title": "illustTitle",
"pageCount": "pageCount",
"userId": "userId",
"userName": "userName",
"width": "width",
"height": "height",
"mangaPages": "mangaPages",
"bookmarkData": "bookmarkData",
"width": "width",
"height": "height",
"createDate": "createDate",
// These are handled separately.
// "tags": "tags",
// "url": "previewUrl",
};
illust_info_early_illust_data_keys = {
"id": "id",
"illustType": "illustType",
"illustTitle": "illustTitle",
"pageCount": "pageCount",
"userId": "userId",
"userName": "userName",
"width": "width",
"height": "height",
"mangaPages": "mangaPages",
"bookmarkData": "bookmarkData",
"width": "width",
"height": "height",
"createDate": "createDate",
// "tags": "tags",
// urls.small: "previewUrl",
};
// Get illustration info that can be retrieved from both
// Get the info we need to set up an image display. We can do this from thumbnail info
// if we're coming from a search, or illust info otherwise. This only blocks if we
// need to load the data.
async get_early_illust_data(illust_id)
{
let keys = null;
let data = thumbnail_data.singleton().get_one_thumbnail_info(illust_id);
let result = { };
if(data)
{
keys = this.thumbnail_info_early_illust_data_keys;
result.previewUrl = data.url;
result.tags = data.tags;
}
else
{
data = await image_data.singleton().get_image_info(illust_id);
if(data == null)
return null;
keys = this.illust_info_early_illust_data_keys;
result.previewUrl = data.urls.small;
result.tags = [];
for(let tag of data.tags.tags)
result.tags.push(tag.tag);
}
// Remap whichever data type we got.
for(let from_key in keys)
{
let to_key = keys[from_key];
if(!(from_key in data))
{
console.warn(\`Missing key \${from_key} for early data\`, data);
continue;
}
result[to_key] = data[from_key];
}
return result;
}
// Update early illust data.
//
// THe data might have come from illust info or thumbnail info, so update whichever
// we have. This can't update fields with special handling, like tags.
update_early_illust_data(illust_id, data)
{
let update_data = (update, keys) => {
for(let from_key in keys)
{
let to_key = keys[from_key];
if(!(from_key in data))
continue;
console.assert(from_key != "tags");
update[to_key] = data[from_key];
}
};
let thumb_data = thumbnail_data.singleton().get_one_thumbnail_info(illust_id);
let tags = null;
if(thumb_data)
update_data(thumb_data, this.thumbnail_info_early_illust_data_keys);
let illust_info = image_data.singleton().get_image_info_sync(illust_id);
if(illust_info != null)
update_data(illust_info, this.illust_info_early_illust_data_keys);
this.call_illust_modified_callbacks(illust_id);
}
// Remember when we've liked an image recently, so we don't spam API requests.
get_liked_recently(illust_id) { return this.recent_likes[illust_id]; }
add_liked_recently(illust_id) { this.recent_likes[illust_id] = true; }
// Convert a verbose tag list from illust info:
//
// illust_info.tags = { tags: [{tag: "tag1"}, {tag: "tag2"}] };
//
// to a simple array of tag names, which is what we get in thumbnail data and
// the format we use in early illust info.
static from_tag_list(tags)
{
let result = [];
for(let tag of tags.tags)
{
result.push(tag.tag);
}
return result;
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/image_data.js
`;
ppixiv.resources["src/on_click_viewer.js"] = `"use strict";
// View img fullscreen. Clicking the image will zoom it to its original size and scroll
// it around.
//
// The image is always zoomed a fixed amount from its fullscreen size. This is generally
// more usable than doing things like zooming based on the native resolution.
ppixiv.on_click_viewer = class
{
constructor()
{
this.onresize = this.onresize.bind(this);
this.pointermove = this.pointermove.bind(this);
this.block_event = this.block_event.bind(this);
this.window_blur = this.window_blur.bind(this);
this.original_width = 1;
this.original_height = 1;
this.center_pos = [0, 0];
// Restore the most recent zoom mode. We assume that there's only one of these on screen.
this.locked_zoom = settings.get("zoom-mode") == "locked";
this._zoom_level = settings.get("zoom-level", "cover");
}
set_new_image(url, preview_url, image_container, width, height)
{
this.disable(false /* !stop_drag */);
this.image_container = image_container;
this.original_width = width;
this.original_height = height;
this.img = document.createElement("img");
this.img.src = url? url:helpers.blank_image;
this.img.className = "filtering";
image_container.appendChild(this.img);
// Create the low-res preview. This loads the thumbnail underneath the main image. Don't set the
// "filtering" class, since using point sampling for the thumbnail doesn't make sense. If preview_url
// is null, just use a blank image.
this.preview_img = document.createElement("img");
this.preview_img.src = preview_url? preview_url:helpers.blank_image;
this.preview_img.classList.add("low-res-preview");
image_container.appendChild(this.preview_img);
// The secondary image holds the low-res preview image that's shown underneath the loading image.
// It just follows the main image around and shouldn't receive input events.
this.preview_img.style.pointerEvents = "none";
// When the image finishes loading, remove the preview image, to prevent artifacts with
// transparent images. Keep a reference to preview_img, so we don't need to worry about
// it changing. on_click_viewer will still have a reference to it, but it won't do anything.
//
// Don't do this if url is null. Leave the preview up and don't switch over to the blank
// image.
let preview_image = this.preview_img;
if(url != null)
{
this.img.addEventListener("load", (e) => {
preview_image.remove();
});
}
this._add_events();
this.reset_position();
this.reposition();
}
disable(stop_drag=true)
{
if(stop_drag)
this.stop_dragging();
this._remove_events();
this.cancel_save_to_history();
if(this.img)
{
this.img.remove();
this.img = null;
}
if(this.preview_img)
{
this.preview_img.remove();
this.preview_img = null;
}
}
// Set the pan position to the default for this image.
reset_position()
{
// Figure out whether the image is relatively portrait or landscape compared to the screen.
let screen_width = Math.max(this.container_width, 1); // might be 0 if we're hidden
let screen_height = Math.max(this.container_height, 1);
let aspect = (screen_width/this.original_width) > (screen_height/this.original_height)? "portrait":"landscape";
// If this.set_initial_image_position is true, then we're changing pages in the same illustration
// and already have a position. If the images are similar, it's useful to keep the same position,
// so you can alternate between variants of an image and have them stay in place. However, if
// they're very different, this just leaves the display in a weird place.
//
// Try to guess. If we have a position already, only replace it if the aspect ratio mode is
// the same. If we're going from portrait to portrait leave the position alone, but if we're
// going from portrait to landscape, reset it.
//
// Note that we'll come here twice when loading most images: once using the preview image and
// then again with the real image. It's important that we not mess with the zoom position on
// the second call.
if(this.set_initial_image_position && aspect != this.initial_image_position_aspect)
this.set_initial_image_position = false;
if(this.set_initial_image_position)
return;
this.set_initial_image_position = true;
this.initial_image_position_aspect = aspect;
// Similar to how we display thumbnails for portrait images starting at the top, default to the top
// if we'll be panning vertically when in cover mode.
let zoom_center = [0.5, aspect == "portrait"? 0:0.5];
this.center_pos = zoom_center;
}
block_event(e)
{
e.preventDefault();
}
_add_events()
{
this._remove_events();
this.event_abort = new AbortController();
window.addEventListener("blur", this.window_blur, { signal: this.event_abort.signal });
window.addEventListener("resize", this.onresize, { signal: this.event_abort.signal, capture: true });
this.image_container.addEventListener("dragstart", this.block_event, { signal: this.event_abort.signal });
this.image_container.addEventListener("selectstart", this.block_event, { signal: this.event_abort.signal });
new ppixiv.pointer_listener({
element: this.image_container,
button_mask: 1,
signal: this.event_abort.signal,
callback: this.pointerevent,
});
// This is like pointermove, but received during quick view from the source tab.
window.addEventListener("quickviewpointermove", this.quick_view_pointermove, { signal: this.event_abort.signal });
this.image_container.style.userSelect = "none";
this.image_container.style.MozUserSelect = "none";
}
_remove_events()
{
if(this.event_abort)
{
this.event_abort.abort();
this.event_abort = null;
}
if(this.image_container)
{
this.image_container.style.userSelect = "none";
this.image_container.style.MozUserSelect = "";
}
}
onresize(e)
{
this.reposition();
}
window_blur(e)
{
this.stop_dragging();
}
// Enable or disable zoom lock.
get locked_zoom()
{
return this._locked_zoom;
}
// Select between click-pan zooming and sticky, filled-screen zooming.
set locked_zoom(enable)
{
this._locked_zoom = enable;
settings.set("zoom-mode", enable? "locked":"normal");
this.reposition();
}
// Relative zoom is applied on top of the main zoom. At 0, no adjustment is applied.
// Positive values zoom in and negative values zoom out.
get zoom_level()
{
return this._zoom_level;
}
set zoom_level(value)
{
this._zoom_level = value;
settings.set("zoom-level", this._zoom_level);
this.reposition();
}
// A zoom level is the exponential ratio the user sees, and the zoom
// factor is just the multiplier.
zoom_level_to_zoom_factor(level) { return Math.pow(1.5, level); }
zoom_factor_to_zoom_level(factor) { return Math.log2(factor) / Math.log2(1.5); }
// Get the effective zoom level, translating "cover" and "actual" to actual values.
get _zoom_level_current()
{
if(!this.zoom_active)
return 0;
let level = this._zoom_level;
if(level == "cover")
return this._zoom_level_cover;
else if(level == "actual")
return this._zoom_level_actual;
else
return level;
}
// Return the active zoom ratio. A zoom of 1x corresponds to "contain" zooming.
get _zoom_factor_current()
{
if(!this.zoom_active)
return 1;
return this.zoom_level_to_zoom_factor(this._zoom_level_current);
}
// The zoom factor for cover mode:
get _zoom_factor_cover() { return Math.max(this.container_width/this.width, this.container_height/this.height); }
get _zoom_level_cover() { return this.zoom_factor_to_zoom_level(this._zoom_factor_cover); }
// The zoom level for "actual" mode:
get _zoom_factor_actual() { return 1 / this._image_to_screen_ratio; }
get _zoom_level_actual() { return this.zoom_factor_to_zoom_level(this._zoom_factor_actual); }
// Zoom in or out. If zoom_in is true, zoom in by one level, otherwise zoom out by one level.
change_zoom(zoom_out)
{
// zoom_level can be a number. At 0 (default), we zoom to fit the image in the screen.
// Higher numbers zoom in, lower numbers zoom out. Zoom levels are logarithmic.
//
// zoom_level can be "cover", which zooms to fill the screen completely, so we only zoom on
// one axis.
//
// zoom_level can also be "actual", which zooms the image to its natural size.
//
// These zoom levels have a natural ordering, which we use for incremental zooming. Figure
// out the zoom levels that correspond to "cover" and "actual". This changes depending on the
// image and screen size.
let cover_zoom_level = this._zoom_level_cover;
let actual_zoom_level = this._zoom_level_actual;
// Increase or decrease relative_zoom_level by snapping to the next or previous increment.
// We're usually on a multiple of increment, moving from eg. 0.5 to 0.75, but if we're on
// a non-increment value from a special zoom level, this puts us back on the zoom increment.
let old_level = this._zoom_level_current;
let new_level = old_level;
let increment = 0.25;
if(zoom_out)
new_level = Math.floor((new_level - 0.001) / increment) * increment;
else
new_level = Math.ceil((new_level + 0.001) / increment) * increment;
// If the amount crosses over one of the special zoom levels above, we select that instead.
let crossed = function(old_value, new_value, threshold)
{
return (old_value < threshold && new_value > threshold) ||
(new_value < threshold && old_value > threshold);
};
if(crossed(old_level, new_level, cover_zoom_level))
{
// console.log("Selected cover zoom");
new_level = "cover";
}
else if(crossed(old_level, new_level, actual_zoom_level))
{
// console.log("Selected actual zoom");
new_level = "actual";
}
else
{
// Clamp relative zooming. Do this here to make sure we can always select cover and actual
// which aren't clamped, even if the image is very large or small.
new_level = helpers.clamp(new_level, -8, +8);
}
this.zoom_level = new_level;
}
// Return the image coordinate at a given screen coordinate.
// return zoom_pos, so this just converts screen coords to unit
get_image_position(screen_pos)
{
let pos = this.current_zoom_pos;
return [
pos[0] + (screen_pos[0] - this.container_width/2) / this.onscreen_width,
pos[1] + (screen_pos[1] - this.container_height/2) / this.onscreen_height,
];
}
// Given a screen position and a point on the image, align the point to the screen
// position. This has no effect when we're not zoomed.
set_image_position(screen_pos, zoom_center, draw=true)
{
this.center_pos = [
-((screen_pos[0] - this.container_width/2) / this.onscreen_width - zoom_center[0]),
-((screen_pos[1] - this.container_height/2) / this.onscreen_height - zoom_center[1]),
];
if(draw)
this.reposition();
}
pointerevent = (e) =>
{
if(e.mouseButton != 0)
return;
if(e.pressed)
{
// We only want clicks on the image, or on the container backing the image, not other
// elements inside the container.
if(e.target != this.img && e.target != this.image_container)
return;
this.image_container.style.cursor = "none";
// Don't show the UI if the mouse hovers over it while dragging.
document.body.classList.add("hide-ui");
if(!this._locked_zoom)
var zoom_center_pos = this.get_image_position([e.pageX, e.pageY]);
this._mouse_pressed = true;
this.dragged_while_zoomed = false;
this.captured_pointer_id = e.pointerId;
this.img.setPointerCapture(this.captured_pointer_id);
// If this is a click-zoom, align the zoom to the point on the image that
// was clicked.
if(!this._locked_zoom)
this.set_image_position([e.pageX, e.pageY], zoom_center_pos);
this.reposition();
// Only listen to pointermove while we're dragging.
this.image_container.addEventListener("pointermove", this.pointermove);
} else {
if(this.captured_pointer_id == null || e.pointerId != this.captured_pointer_id)
return;
if(!this._mouse_pressed)
return;
// Tell hide_mouse_cursor_on_idle that the mouse cursor should be hidden, even though the
// cursor may have just been moved. This prevents the cursor from appearing briefly and
// disappearing every time a zoom is released.
track_mouse_movement.singleton.simulate_inactivity();
this.stop_dragging();
}
}
stop_dragging()
{
if(this.image_container != null)
{
this.image_container.removeEventListener("pointermove", this.pointermove);
this.image_container.style.cursor = "";
}
if(this.captured_pointer_id != null)
{
this.img.releasePointerCapture(this.captured_pointer_id);
this.captured_pointer_id = null;
}
document.body.classList.remove("hide-ui");
this._mouse_pressed = false;
this.reposition();
}
pointermove(e)
{
if(!this._mouse_pressed)
return;
this.dragged_while_zoomed = true;
this.apply_pointer_movement({movementX: e.movementX, movementY: e.movementY});
}
quick_view_pointermove = (e) =>
{
this.apply_pointer_movement({movementX: e.movementX, movementY: e.movementY});
}
apply_pointer_movement({movementX, movementY})
{
// Apply mouse dragging.
let x_offset = movementX;
let y_offset = movementY;
if(settings.get("invert-scrolling"))
{
x_offset *= -1;
y_offset *= -1;
}
// This will make mouse dragging match the image exactly:
x_offset /= this.onscreen_width;
y_offset /= this.onscreen_height;
// Scale movement by the zoom factor, so we move faster if we're zoomed
// further in.
let zoom_factor = this._zoom_factor_current;
x_offset *= zoom_factor;
y_offset *= zoom_factor;
this.center_pos[0] += x_offset;
this.center_pos[1] += y_offset;
this.reposition();
}
// Return true if zooming is active.
get zoom_active()
{
return this._mouse_pressed || this._locked_zoom;
}
get _image_to_screen_ratio()
{
let screen_width = this.container_width;
let screen_height = this.container_height;
// In case we're hidden and have no width, make sure we don't return an invalid value.
if(screen_width == 0 || screen_height == 0)
return 1;
return Math.min(screen_width/this.original_width, screen_height/this.original_height);
}
// Return the width and height of the image when at 1x zoom.
get width() { return this.original_width * this._image_to_screen_ratio; }
get height() { return this.original_height * this._image_to_screen_ratio; }
// The actual size of the image with its current zoom.
get onscreen_width() { return this.width * this._zoom_factor_current; }
get onscreen_height() { return this.height * this._zoom_factor_current; }
// The dimensions of the image viewport. This can be 0 if the view is hidden.
get container_width() { return this.image_container.offsetWidth || 0; }
get container_height() { return this.image_container.offsetHeight || 0; }
get current_zoom_pos()
{
if(this.zoom_active)
return this.center_pos;
else
return [0.5, 0.5];
}
reposition()
{
if(this.img == null)
return;
// Stop if we're being called after being disabled.
if(this.image_container == null)
return;
this.schedule_save_to_history();
let screen_width = this.container_width;
let screen_height = this.container_height;
var width = this.width;
var height = this.height;
// If the dimensions are empty then we aren't loaded. Stop now, so the math
// below doesn't break.
if(width == 0 || height == 0 || screen_width == 0 || screen_height == 0)
return;
// When we're zooming to fill the screen, clamp panning to the screen, so we always fill the
// screen and don't pan past the edge.
if(this.zoom_active && !settings.get("pan-past-edge"))
{
let top_left = this.get_image_position([0,0]);
top_left[0] = Math.max(top_left[0], 0);
top_left[1] = Math.max(top_left[1], 0);
this.set_image_position([0,0], top_left, false);
let bottom_right = this.get_image_position([screen_width,screen_height]);
bottom_right[0] = Math.min(bottom_right[0], 1);
bottom_right[1] = Math.min(bottom_right[1], 1);
this.set_image_position([screen_width,screen_height], bottom_right, false);
}
let zoom_factor = this._zoom_factor_current;
let zoomed_width = width * zoom_factor;
let zoomed_height = height * zoom_factor;
// If we're narrower than the screen, lock to the middle.
if(screen_width >= zoomed_width)
this.center_pos[0] = 0.5; // center horizontally
if(screen_height >= zoomed_height)
this.center_pos[1] = 0.5; // center vertically
// Normally (when unzoomed), the image is centered.
let [x, y] = this.current_zoom_pos;
this.img.style.width = this.width + "px";
this.img.style.height = this.height + "px";
this.img.style.position = "absolute";
// We can either use CSS positioning or transforms. Transforms used to be a lot
// faster, but today it doesn't matter. However, with CSS positioning we run into
// weird Firefox compositing bugs that cause the image to disappear after zooming
// and opening the context menu. That's hard to pin down, but since it doesn't happen
// with translate, let's just use that.
this.img.style.transformOrigin = "0 0";
this.img.style.transform =
\`translate(\${screen_width/2}px, \${screen_height/2}px) \` +
\`scale(\${zoom_factor}) \` +
\`translate(\${-this.width * x}px, \${-this.height * y}px) \` +
\`\`;
this.img.style.right = "auto";
this.img.style.bottom = "auto";
// If we have a secondary (preview) image, put it in the same place as the main image.
if(this.preview_img)
{
this.preview_img.style.width = this.width + "px";
this.preview_img.style.height = this.height + "px";
this.preview_img.style.position = "absolute";
this.preview_img.style.right = "auto";
this.preview_img.style.bottom = "auto";
this.preview_img.style.transformOrigin = "0 0";
this.preview_img.style.transform = this.img.style.transform;
}
// Store the effective zoom in our tab info. This is in a format that makes it easy
// to replicate the zoom in other UIs. Send this as extra data in the tab info. This
// data isn't sent in realtime, since it would spam broadcasts as we zoom. It's just
// sent when we lose focus.
let top_left = this.get_image_position([0,0]);
let current_zoom_desc = {
left: -top_left[0] * this.onscreen_width / screen_width, // convert from image size to fraction of screen size
top: -top_left[1] * this.onscreen_height / screen_height,
width: zoom_factor * width / screen_width,
height: zoom_factor * height / screen_height,
};
SendImage.set_extra_data("illust_screen_pos", current_zoom_desc, true);
}
// Restore the pan and zoom state from history.
restore_from_history = () =>
{
let args = helpers.args.location;
if(args.state.zoom == null)
return;
this.zoom_level = args.state.zoom?.zoom;
this.locked_zoom = args.state.zoom?.lock;
this.center_pos = [...args.state.zoom?.pos];
this.reposition();
this.set_initial_image_position = true;
}
// Save the pan and zoom state to history.
save_to_history = () =>
{
this.save_to_history_id = null;
// Store the pan position at the center of the screen.
let args = helpers.args.location;
let screen_pos = [this.container_width / 2, this.container_height / 2];
args.state.zoom = {
pos: this.center_pos,
zoom: this.zoom_level,
lock: this.locked_zoom,
};
helpers.set_page_url(args, false /* add_to_history */);
}
// Schedule save_to_history to run. This is buffered so we don't call history.replaceState
// too quickly.
schedule_save_to_history()
{
this.cancel_save_to_history();
this.save_to_history_id = setTimeout(this.save_to_history, 250);
}
cancel_save_to_history()
{
if(this.save_to_history_id != null)
{
clearTimeout(this.save_to_history_id);
this.save_to_history_id = null;
}
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/on_click_viewer.js
`;
ppixiv.resources["src/polyfills.js"] = `"use strict";
ppixiv.install_polyfills = function()
{
// Return true if name exists, eg. GM_xmlhttpRequest.
var script_global_exists = function(name)
{
// For some reason, the script globals like GM and GM_xmlhttpRequest aren't
// in window, so it's not clear how to check if they exist. Just try to
// access it and catch the ReferenceError exception if it doesn't exist.
try {
eval(name);
return true;
} catch(e) {
return false;
}
};
// If we have GM.xmlHttpRequest and not GM_xmlhttpRequest, set GM_xmlhttpRequest.
if(script_global_exists("GM") && GM.xmlHttpRequest && !script_global_exists("GM_xmlhttpRequest"))
window.GM_xmlhttpRequest = GM.xmlHttpRequest;
// padStart polyfill:
// https://github.com/uxitten/polyfill/blob/master/string.polyfill.js
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padStart
if(!String.prototype.padStart) {
String.prototype.padStart = function padStart(targetLength,padString) {
targetLength = targetLength>>0; //truncate if number or convert non-number to 0;
padString = String((typeof padString !== 'undefined' ? padString : ' '));
if (this.length > targetLength) {
return String(this);
}
else {
targetLength = targetLength-this.length;
if (targetLength > padString.length) {
padString += padString.repeat(targetLength/padString.length); //append to original to ensure we are longer than needed
}
return padString.slice(0,targetLength) + String(this);
}
};
}
if(!("requestFullscreen" in Element.prototype))
{
// Web API prefixing needs to be shot into the sun.
if("webkitRequestFullScreen" in Element.prototype)
{
Element.prototype.requestFullscreen = Element.prototype.webkitRequestFullScreen;
HTMLDocument.prototype.exitFullscreen = HTMLDocument.prototype.webkitCancelFullScreen;
Object.defineProperty(HTMLDocument.prototype, "fullscreenElement", {
get: function() { return this.webkitFullscreenElement; }
});
}
else if("mozRequestFullScreen" in Element.prototype)
{
Element.prototype.requestFullscreen = Element.prototype.mozRequestFullScreen;
HTMLDocument.prototype.exitFullscreen = HTMLDocument.prototype.mozCancelFullScreen;
Object.defineProperty(HTMLDocument.prototype, "fullscreenElement", {
get: function() { return this.mozFullScreenElement; }
});
}
}
// Workaround for "Violentmonkey", which is missing exportFunction:
if(!("exportFunction" in window))
{
window.exportFunction = function(func)
{
return func;
};
}
// Make IDBRequest an async generator.
//
// Note that this will clobber onsuccess and onerror on the IDBRequest.
if(!IDBRequest.prototype[Symbol.asyncIterator])
{
// This is awful (is there no syntax sugar to make this more readable?), but it
// makes IDBRequests much more sane to use.
IDBRequest.prototype[Symbol.asyncIterator] = function() {
return {
next: () => {
return new Promise((accept, reject) => {
this.onsuccess = (e) => {
let entry = e.target.result;
if(entry == null)
{
accept({ done: true });
return;
}
accept({ value: entry, done: false });
entry.continue();
}
this.onerror = (e) => {
reject(e);
};
});
}
};
};
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/polyfills.js
`;
ppixiv.resources["src/progress_bar.js"] = `"use strict";
// A simple progress bar.
//
// Call bar.controller() to create a controller to update the progress bar.
ppixiv.progress_bar = class
{
constructor(container)
{
this.container = container;
this.bar = this.container.appendChild(helpers.create_node('\\
\\
\\
'));
this.bar.hidden = true;
};
// Create a progress_bar_controller for this progress bar.
//
// If there was a previous controller, it will be detached.
controller()
{
if(this.current_controller)
{
this.current_controller.detach();
this.current_controller = null;
}
this.current_controller = new progress_bar_controller(this);
return this.current_controller;
}
}
// This handles updating a progress_bar.
//
// This is separated from progress_bar, which allows us to transparently detach
// the controller from a progress_bar.
//
// For example, if we load a video file and show the loading in the progress bar, and
// the user then navigates to another video, we detach the first controller. This way,
// the new load will take over the progress bar (whether or not we actually cancel the
// earlier load) and progress bar users won't fight with each other.
ppixiv.progress_bar_controller = class
{
constructor(bar)
{
this.progress_bar = bar;
}
set(value)
{
if(this.progress_bar == null)
return;
this.progress_bar.bar.hidden = (value == null);
this.progress_bar.bar.classList.remove("hide");
this.progress_bar.bar.getBoundingClientRect();
if(value != null)
this.progress_bar.bar.style.width = (value * 100) + "%";
}
// Flash the current progress value and fade out.
show_briefly()
{
this.progress_bar.bar.classList.add("hide");
}
detach()
{
this.progress_bar = null;
}
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/progress_bar.js
`;
ppixiv.resources["src/seek_bar.js"] = `"use strict";
ppixiv.seek_bar = class
{
constructor(container)
{
this.mousedown = this.mousedown.bind(this);
this.mouseup = this.mouseup.bind(this);
this.mousemove = this.mousemove.bind(this);
this.mouseover = this.mouseover.bind(this);
this.mouseout = this.mouseout.bind(this);
this.container = container;
this.bar = this.container.appendChild(helpers.create_node('\\
\\
'));
this.bar.addEventListener("mousedown", this.mousedown);
this.bar.addEventListener("mouseover", this.mouseover);
this.bar.addEventListener("mouseout", this.mouseout);
this.current_time = 0;
this.duration = 1;
this.refresh_visibility();
this.refresh();
this.set_callback(null);
};
mousedown(e)
{
// Never start dragging while we have no callback. This generally shouldn't happen
// since we should be hidden.
if(this.callback == null)
return;
if(this.dragging)
return;
e.preventDefault();
this.dragging = true;
helpers.set_class(this.bar, "dragging", this.dragging);
this.refresh_visibility();
// Only listen to mousemove while we're dragging. Put this on window, so we get drags outside
// the window.
window.addEventListener("mousemove", this.mousemove);
window.addEventListener("mouseup", this.mouseup);
this.set_drag_pos(e);
}
mouseover()
{
this.hovering = true;
this.refresh_visibility();
}
mouseout()
{
this.hovering = false;
this.refresh_visibility();
}
refresh_visibility()
{
// Show the seek bar if the mouse is over it, or if we're actively dragging.
// Only show if we're active.
var visible = this.callback != null && (this.hovering || this.dragging);
helpers.set_class(this.bar, "visible", visible);
}
stop_dragging()
{
if(!this.dragging)
return;
this.dragging = false;
helpers.set_class(this.bar, "dragging", this.dragging);
this.refresh_visibility();
window.removeEventListener("mousemove", this.mousemove);
window.removeEventListener("mouseup", this.mouseup);
if(this.callback)
this.callback(false, null);
}
mouseup(e)
{
this.stop_dragging();
}
mousemove(e)
{
this.set_drag_pos(e);
}
// The user clicked or dragged. Pause and seek to the clicked position.
set_drag_pos(e)
{
// Get the mouse position relative to the seek bar.
var bounds = this.bar.getBoundingClientRect();
var pos = (e.clientX - bounds.left) / bounds.width;
pos = Math.max(0, Math.min(1, pos));
var time = pos * this.duration;
// Tell the user to seek.
this.callback(true, time);
}
// Set the callback. callback(pause, time) will be called when the user interacts
// with the seek bar. The first argument is true if the video should pause (because
// the user is dragging the seek bar), and time is the desired playback time. If callback
// is null, remove the callback.
set_callback(callback)
{
this.bar.hidden = callback == null;
if(this.callback == callback)
return;
// Stop dragging on any previous caller before we replace the callback.
if(this.callback != null)
this.stop_dragging();
this.callback = callback;
this.refresh_visibility();
};
set_duration(seconds)
{
this.duration = seconds;
this.refresh();
};
set_current_time(seconds)
{
this.current_time = seconds;
this.refresh();
};
refresh()
{
var position = this.duration > 0.0001? (this.current_time / this.duration):0;
this.bar.querySelector(".seek-fill").style.width = (position * 100) + "%";
};
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/seek_bar.js
`;
ppixiv.resources["src/struct.js"] = `"use strict";
// https://github.com/lyngklip/structjs/blob/master/struct.js
// The MIT License (MIT)
// Copyright (c) 2016 Aksel Jensen (TheRealAksel at github)
// This is completely unreadable. Why would anyone write JS like this?
/*eslint-env es6, node*/
ppixiv.struct = (function() {
const rechk = /^([<>])?(([1-9]\\d*)?([xcbB?hHiIfdsp]))*\$/
const refmt = /([1-9]\\d*)?([xcbB?hHiIfdsp])/g
const str = (v,o,c) => String.fromCharCode(
...new Uint8Array(v.buffer, v.byteOffset + o, c))
const rts = (v,o,c,s) => new Uint8Array(v.buffer, v.byteOffset + o, c)
.set(s.split('').map(str => str.charCodeAt(0)))
const pst = (v,o,c) => str(v, o + 1, Math.min(v.getUint8(o), c - 1))
const tsp = (v,o,c,s) => { v.setUint8(o, s.length); rts(v, o + 1, c - 1, s) }
const lut = le => ({
x: c=>[1,c,0],
c: c=>[c,1,o=>({u:v=>str(v, o, 1) , p:(v,c)=>rts(v, o, 1, c) })],
'?': c=>[c,1,o=>({u:v=>Boolean(v.getUint8(o)),p:(v,B)=>v.setUint8(o,B)})],
b: c=>[c,1,o=>({u:v=>v.getInt8( o ), p:(v,b)=>v.setInt8( o,b )})],
B: c=>[c,1,o=>({u:v=>v.getUint8( o ), p:(v,B)=>v.setUint8( o,B )})],
h: c=>[c,2,o=>({u:v=>v.getInt16( o,le), p:(v,h)=>v.setInt16( o,h,le)})],
H: c=>[c,2,o=>({u:v=>v.getUint16( o,le), p:(v,H)=>v.setUint16( o,H,le)})],
i: c=>[c,4,o=>({u:v=>v.getInt32( o,le), p:(v,i)=>v.setInt32( o,i,le)})],
I: c=>[c,4,o=>({u:v=>v.getUint32( o,le), p:(v,I)=>v.setUint32( o,I,le)})],
f: c=>[c,4,o=>({u:v=>v.getFloat32(o,le), p:(v,f)=>v.setFloat32(o,f,le)})],
d: c=>[c,8,o=>({u:v=>v.getFloat64(o,le), p:(v,d)=>v.setFloat64(o,d,le)})],
s: c=>[1,c,o=>({u:v=>str(v,o,c), p:(v,s)=>rts(v,o,c,s.slice(0,c ) )})],
p: c=>[1,c,o=>({u:v=>pst(v,o,c), p:(v,s)=>tsp(v,o,c,s.slice(0,c - 1) )})]
})
const errbuf = new RangeError("Structure larger than remaining buffer")
const errval = new RangeError("Not enough values for structure")
const struct = format => {
let fns = [], size = 0, m = rechk.exec(format)
if (!m) { throw new RangeError("Invalid format string") }
const t = lut('<' === m[1]), lu = (n, c) => t[c](n ? parseInt(n, 10) : 1)
while ((m = refmt.exec(format))) { ((r, s, f) => {
for (let i = 0; i < r; ++i, size += s) { if (f) {fns.push(f(size))} }
})(...lu(...m.slice(1)))}
const unpack_from = (arrb, offs) => {
if (arrb.byteLength < (offs|0) + size) { throw errbuf }
let v = new DataView(arrb, offs|0)
return fns.map(f => f.u(v))
}
const pack_into = (arrb, offs, ...values) => {
if (values.length < fns.length) { throw errval }
if (arrb.byteLength < offs + size) { throw errbuf }
const v = new DataView(arrb, offs)
new Uint8Array(arrb, offs, size).fill(0)
fns.forEach((f, i) => f.p(v, values[i]))
}
const pack = (...values) => {
let b = new ArrayBuffer(size)
pack_into(b, 0, ...values)
return b
}
const unpack = arrb => unpack_from(arrb, 0)
function* iter_unpack(arrb) {
for (let offs = 0; offs + size <= arrb.byteLength; offs += size) {
yield unpack_from(arrb, offs);
}
}
return Object.freeze({
unpack, pack, unpack_from, pack_into, iter_unpack, format, size})
}
return struct;
})();
/*
const pack = (format, ...values) => struct(format).pack(...values)
const unpack = (format, buffer) => struct(format).unpack(buffer)
const pack_into = (format, arrb, offs, ...values) =>
struct(format).pack_into(arrb, offs, ...values)
const unpack_from = (format, arrb, offset) =>
struct(format).unpack_from(arrb, offset)
const iter_unpack = (format, arrb) => struct(format).iter_unpack(arrb)
const calcsize = format => struct(format).size
module.exports = {
struct, pack, unpack, pack_into, unpack_from, iter_unpack, calcsize }
*/
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/struct.js
`;
ppixiv.resources["src/ugoira_downloader_mjpeg.js"] = `"use strict";
// Encode a Pixiv video to MJPEG, using an MKV container.
//
// Other than having to wrangle the MKV format, this is easy: the source files appear to always
// be JPEGs, so we don't need to do any conversions and the encoding is completely lossless (other
// than the loss Pixiv forces by reencoding everything to JPEG). The result is standard and plays
// in eg. VLC, but it's not a WebM file and browsers don't support it.
ppixiv.ugoira_downloader_mjpeg = class
{
constructor(illust_data, progress)
{
this.illust_data = illust_data;
this.onprogress = progress;
this.metadata = illust_data.ugoiraMetadata;
this.mime_type = illust_data.ugoiraMetadata.mime_type;
this.frames = [];
this.load_all_frames();
}
async load_all_frames()
{
let downloader = new ZipImageDownloader(this.metadata.originalSrc, {
onprogress: (progress) => {
if(!this.onprogress)
return;
try {
this.onprogress.set(progress);
} catch(e) {
console.error(e);
}
},
});
while(1)
{
let file = await downloader.get_next_frame();
if(file == null)
break;
this.frames.push(file);
}
// Some posts have the wrong dimensions in illust_data (63162632). If we use it, the resulting
// file won't play. Decode the first image to find the real resolution.
var img = document.createElement("img");
var blob = new Blob([this.frames[0]], {type: this.mime_type || "image/png"});
var first_frame_url = URL.createObjectURL(blob);
img.src = first_frame_url;
await helpers.wait_for_image_load(img);
URL.revokeObjectURL(first_frame_url);
let width = img.naturalWidth;
let height = img.naturalHeight;
try {
var encoder = new encode_mkv(width, height);
// Add each frame to the encoder.
var frame_count = this.illust_data.ugoiraMetadata.frames.length;
for(var frame = 0; frame < frame_count; ++frame)
{
var frame_data = this.frames[frame];
let duration = this.metadata.frames[frame].delay;
encoder.add(frame_data, duration);
};
// There's no way to encode the duration of the final frame of an MKV, which means the last frame
// will be effectively lost when looping. In theory the duration field on the file should tell the
// player this, but at least VLC doesn't do that.
//
// Work around this by repeating the last frame with a zero duration.
//
// In theory we could set the "invisible" bit on this frame ("decoded but not displayed"), but that
// doesn't seem to be used, at least not by VLC.
var frame_data = this.frames[frame_count-1];
encoder.add(frame_data, 0);
// Build the file.
var mkv = encoder.build();
var filename = this.illust_data.userName + " - " + this.illust_data.illustId + " - " + this.illust_data.illustTitle + ".mkv";
helpers.save_blob(mkv, filename);
} catch(e) {
console.error(e);
};
// Completed:
if(this.onprogress)
this.onprogress.set(null);
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/ugoira_downloader_mjpeg.js
`;
ppixiv.resources["src/viewer.js"] = `"use strict";
// This is the base class for viewer classes, which are used to view a particular
// type of content in the main display.
ppixiv.viewer = class
{
constructor(container, illust_id)
{
this.illust_id = illust_id;
this.active = false;
}
// Remove any event listeners, nodes, etc. and shut down so a different viewer can
// be used.
shutdown()
{
this.was_shutdown = true;
}
set page(page) { }
get page() { return 0; }
set active(value) { this._active = value; }
get active() { return this._active; }
// Return the file type for display in the UI, eg. "PNG".
get current_image_type() { return null; }
// If an image is displayed, clear it.
//
// This is only used with the illust viewer when changing manga pages in cases
// where we don't want the old image to be displayed while the new one loads.
set hide_image(value) { }
get hide_image() { return false; }
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/viewer.js
`;
ppixiv.resources["src/viewer_images.js"] = `"use strict";
// This is the viewer for static images. We take an illust_data and show
// either a single image or navigate between an image sequence.
ppixiv.viewer_images = class extends ppixiv.viewer
{
constructor(container, illust_id, options)
{
super(container, illust_id);
this.container = container;
this.options = options || {};
this.manga_page_bar = options.manga_page_bar;
this.onkeydown = this.onkeydown.bind(this);
this.index = options.manga_page || 0;
// Create a click and drag viewer for the image.
this.on_click_viewer = new on_click_viewer();
main_context_menu.get.on_click_viewer = this.on_click_viewer;
this.load();
}
async load()
{
// First, load early illust data. This is enough info to set up the image list
// with preview URLs, so we can start the image view early.
//
// If this blocks to load, the full illust data will be loaded, so we'll never
// run two separate requests here.
let early_illust_data = await image_data.singleton().get_early_illust_data(this.illust_id);
// Stop if we were removed before the request finished.
if(this.was_shutdown)
return;
// Only add an entry for page 1. We don't have image dimensions for manga pages from
// early data, so we can't use them for quick previews.
this.images = [{
url: null,
preview_url: early_illust_data.previewUrl,
width: early_illust_data.width,
height: early_illust_data.height,
}];
this.refresh();
// Now wait for full illust info to load.
this.illust_data = await image_data.singleton().get_image_info(this.illust_id);
// Stop if we were removed before the request finished.
if(this.was_shutdown)
return;
// Update the list to include the image URLs.
this.images = [];
for(var page of this.illust_data.mangaPages)
{
this.images.push({
url: page.urls.original,
preview_url: page.urls.small,
width: page.width,
height: page.height,
});
}
this.refresh();
}
// Note that this will always return JPG if all we have is the preview URL.
get current_image_type()
{
return helpers.get_extension(this.url).toUpperCase();
}
shutdown()
{
super.shutdown();
if(this.on_click_viewer)
{
this.on_click_viewer.disable();
this.on_click_viewer = null;
}
main_context_menu.get.on_click_viewer = null;
}
get page()
{
return this.index;
}
set page(page)
{
this.index = page;
this.refresh();
}
refresh()
{
// If we don't have this.images, load() hasn't set it up yet.
if(this.images == null)
return;
// This will be null if this is a manga page that we don't have any info for yet.
let current_image = this.images[this.index];
if(current_image == null)
{
console.info(\`No info for page \${this.index} yet\`);
return;
}
if(this.on_click_viewer &&
current_image.url == this.on_click_viewer.url &&
current_image.preview_url == this.on_click_viewer.preview_url)
return;
// Create the new image and pass it to the viewer.
this.url = current_image.url || current_image.preview_url;
this.on_click_viewer.set_new_image(current_image.url, current_image.preview_url,
this.container, current_image.width, current_image.height);
// Decode the next and previous image. This reduces flicker when changing pages
// since the image will already be decoded.
if(this.index > 0 && this.index - 1 < this.images.length)
helpers.decode_image(this.images[this.index - 1].url);
if(this.index + 1 < this.images.length)
helpers.decode_image(this.images[this.index + 1].url);
// If we have a manga_page_bar, update to show the current page.
if(this.manga_page_bar)
{
if(this.images.length == 1)
this.manga_page_bar.set(null);
else
this.manga_page_bar.set((this.index+1) / this.images.length);
}
// If we were created with the restore_history option set, restore it now that
// we have an image set up. This is done when we're restoring a browser state, so
// only do this the first time.
if(this.options.restore_history)
{
this.on_click_viewer.restore_from_history();
this.options.restore_history = false;
}
}
onkeydown(e)
{
if(e.ctrlKey || e.altKey || e.metaKey)
return;
switch(e.keyCode)
{
case 36: // home
e.stopPropagation();
e.preventDefault();
main_controller.singleton.show_illust(this.illust_data.id, {
page: 0,
});
return;
case 35: // end
e.stopPropagation();
e.preventDefault();
main_controller.singleton.show_illust(this.illust_data.id, {
page: this.illust_data.pageCount - 1,
});
return;
}
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/viewer_images.js
`;
ppixiv.resources["src/viewer_muted.js"] = `"use strict";
// This is used to display a muted image.
ppixiv.viewer_muted = class extends ppixiv.viewer
{
constructor(container, illust_id)
{
super(container, illust_id);
this.container = container;
// Create the display.
this.root = helpers.create_from_template(".template-muted");
container.appendChild(this.root);
this.load();
}
async load()
{
this.root.querySelector(".view-muted-image").addEventListener("click", (e) => {
let args = helpers.args.location;
args.hash.set("view-muted", "1");
helpers.set_page_url(args, false /* add_to_history */, "override-mute");
});
this.illust_data = await image_data.singleton().get_image_info(this.illust_id);
// Stop if we were removed before the request finished.
if(this.was_shutdown)
return;
// Show the user's avatar instead of the muted image.
let user_info = await image_data.singleton().get_user_info(this.illust_data.userId);
var img = this.root.querySelector(".muted-image");
img.src = user_info.imageBig;
let muted_tag = muting.singleton.any_tag_muted(this.illust_data.tags.tags);
let muted_user = muting.singleton.is_muted_user_id(this.illust_data.userId);
let muted_label = this.root.querySelector(".muted-label");
if(muted_tag)
tag_translations.get().set_translated_tag(muted_label, muted_tag);
else if(muted_user)
muted_label.innerText = this.illust_data.userName;
}
shutdown()
{
super.shutdown();
this.root.parentNode.removeChild(this.root);
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/viewer_muted.js
`;
ppixiv.resources["src/viewer_ugoira.js"] = `"use strict";
ppixiv.viewer_ugoira = class extends ppixiv.viewer
{
constructor(container, illust_id, options)
{
super(container, illust_id);
this.refresh_focus = this.refresh_focus.bind(this);
this.clicked_canvas = this.clicked_canvas.bind(this);
this.onkeydown = this.onkeydown.bind(this);
this.drew_frame = this.drew_frame.bind(this);
this.progress = this.progress.bind(this);
this.seek_callback = this.seek_callback.bind(this);
this.container = container;
this.options = options;
this.seek_bar = options.seek_bar;
this.seek_bar.set_current_time(0);
this.seek_bar.set_callback(this.seek_callback);
// Create a canvas to render into.
this.canvas = document.createElement("canvas");
this.canvas.hidden = true;
this.canvas.className = "filtering";
this.canvas.style.width = "100%";
this.canvas.style.height = "100%";
this.canvas.style.objectFit = "contain";
this.container.appendChild(this.canvas);
this.canvas.addEventListener("click", this.clicked_canvas, false);
// True if we want to play if the window has focus. We always pause when backgrounded.
let args = helpers.args.location;
this.want_playing = !args.state.paused;
// True if the user is seeking. We temporarily pause while seeking. This is separate
// from this.want_playing so we stay paused after seeking if we were paused at the start.
this.seeking = false;
window.addEventListener("visibilitychange", this.refresh_focus);
// This can be used to abort ZipImagePlayer's download.
this.abort_controller = new AbortController;
this.load();
}
async load()
{
this.illust_data = await image_data.singleton().get_image_info(this.illust_id);
// Stop if we were removed before the request finished.
if(this.was_shutdown)
return;
this.create_preview_images(this.illust_data);
// Create the player.
this.player = new ZipImagePlayer({
metadata: this.illust_data.ugoiraMetadata,
autoStart: false,
source: this.illust_data.ugoiraMetadata.originalSrc,
mime_type: this.illust_data.ugoiraMetadata.mime_type,
signal: this.abort_controller.signal,
autosize: true,
canvas: this.canvas,
loop: true,
debug: false,
progress: this.progress,
drew_frame: this.drew_frame,
});
this.refresh_focus();
}
async create_preview_images(illust_data)
{
// Create an image to display the static image while we load.
//
// Like static image viewing, load the thumbnail, then the main image on top, since
// the thumbnail will often be visible immediately.
this.preview_img1 = document.createElement("img");
this.preview_img1.classList.add("low-res-preview");
this.preview_img1.style.position = "absolute";
this.preview_img1.style.width = "100%";
this.preview_img1.style.height = "100%";
this.preview_img1.style.objectFit = "contain";
this.preview_img1.src = illust_data.urls.small;
this.container.appendChild(this.preview_img1);
this.preview_img2 = document.createElement("img");
this.preview_img2.style.position = "absolute";
this.preview_img2.className = "filtering";
this.preview_img2.style.width = "100%";
this.preview_img2.style.height = "100%";
this.preview_img2.style.objectFit = "contain";
this.preview_img2.src = illust_data.urls.original;
this.container.appendChild(this.preview_img2);
// Allow clicking the previews too, so if you click to pause the video before it has enough
// data to start playing, it'll still toggle to paused.
this.preview_img1.addEventListener("click", this.clicked_canvas, false);
this.preview_img2.addEventListener("click", this.clicked_canvas, false);
// Wait for the high-res image to finish loading.
helpers.wait_for_image_load(this.preview_img2);
// Remove the low-res preview image when the high-res one finishes loading.
this.preview_img1.remove();
}
set active(active)
{
super.active = active;
// Rewind the video when we're not visible.
if(!active && this.player != null)
this.player.rewind();
// Refresh playback, since we pause while the viewer isn't visible.
this.refresh_focus();
}
progress(value)
{
if(this.options.progress_bar)
this.options.progress_bar.set(value);
if(value == null)
{
// Once we send "finished", don't make any more progress calls.
this.options.progress_bar = null;
}
}
// Once we draw a frame, hide the preview and show the canvas. This avoids
// flicker when the first frame is drawn.
drew_frame()
{
if(this.preview_img1)
this.preview_img1.hidden = true;
if(this.preview_img2)
this.preview_img2.hidden = true;
this.canvas.hidden = false;
if(this.seek_bar)
{
// Update the seek bar.
var frame_time = this.player.get_current_frame_time();
this.seek_bar.set_current_time(this.player.get_current_frame_time());
this.seek_bar.set_duration(this.player.get_seekable_duration());
}
}
// This is sent manually by the UI handler so we can control focus better.
onkeydown(e)
{
if(e.keyCode >= 49 && e.keyCode <= 57)
{
// 5 sets the speed to default, 1234 slow the video down, and 6789 speed it up.
e.stopPropagation();
e.preventDefault();
if(!this.player)
return;
var speed;
switch(e.keyCode)
{
case 49: speed = 0.10; break; // 1
case 50: speed = 0.25; break; // 2
case 51: speed = 0.50; break; // 3
case 52: speed = 0.75; break; // 4
case 53: speed = 1.00; break; // 5
case 54: speed = 1.25; break; // 6
case 55: speed = 1.50; break; // 7
case 56: speed = 1.75; break; // 8
case 57: speed = 2.00; break; // 9
}
this.player.set_speed(speed);
return;
}
switch(e.keyCode)
{
case 32: // space
e.stopPropagation();
e.preventDefault();
this.set_want_playing(!this.want_playing);
return;
case 36: // home
e.stopPropagation();
e.preventDefault();
if(!this.player)
return;
this.player.rewind();
return;
case 35: // end
e.stopPropagation();
e.preventDefault();
if(!this.player)
return;
this.pause();
this.player.set_current_frame(this.player.get_frame_count() - 1);
return;
case 81: // q
case 87: // w
e.stopPropagation();
e.preventDefault();
if(!this.player)
return;
this.pause();
var current_frame = this.player.get_current_frame();
var next = e.keyCode == 87;
var new_frame = current_frame + (next?+1:-1);
this.player.set_current_frame(new_frame);
return;
}
}
play()
{
this.set_want_playing(true);
}
pause()
{
this.set_want_playing(false);
}
// Set whether the user wants the video to be playing or paused.
set_want_playing(value)
{
if(this.want_playing != value)
{
// Store the play/pause state in history, so if we navigate out and back in while
// paused, we'll stay paused.
let args = helpers.args.location;
args.state.paused = !value;
helpers.set_page_url(args, false, "updating-video-pause");
this.want_playing = value;
}
this.refresh_focus();
}
shutdown()
{
super.shutdown();
// Cancel the player's download.
this.abort_controller.abort();
if(this.seek_bar)
{
this.seek_bar.set_callback(null);
this.seek_bar = null;
}
window.removeEventListener("visibilitychange", this.refresh_focus);
// Send a finished progress callback if we were still loading. We won't
// send any progress calls after this.
this.progress(null);
if(this.player)
this.player.pause();
if(this.preview_img1)
{
this.preview_img1.remove();
this.preview_img1 = null;
}
if(this.preview_img2)
{
this.preview_img2.remove();
this.preview_img2 = null;
}
this.canvas.remove();
}
refresh_focus()
{
if(this.player == null)
return;
let active = this.want_playing && !this.seeking && !window.document.hidden && this._active;
if(active)
this.player.play();
else
this.player.pause();
};
clicked_canvas(e)
{
this.set_want_playing(!this.want_playing);
this.refresh_focus();
}
// This is called when the user interacts with the seek bar.
seek_callback(pause, seconds)
{
this.seeking = pause;
this.refresh_focus();
if(seconds != null)
this.player.set_current_frame_time(seconds);
};
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/viewer_ugoira.js
`;
ppixiv.resources["src/zip_image_player.js"] = `"use strict";
// A wrapper for the clunky ReadableStream API that lets us do at basic
// thing that API forgot about: read a given number of bytes at a time.
ppixiv.IncrementalReader = class
{
constructor(reader, options={})
{
this.reader = reader;
this.position = 0;
// Check if this is an ArrayBuffer. "reader instanceof ArrayBuffer" is
// broken in Firefox (but what isn't?).
if("byteLength" in reader)
{
this.input_buffer = new Int8Array(reader);
this.input_buffer_finished = true;
}
else
{
this.input_buffer = new Int8Array(0);
this.input_buffer_finished = false;
}
// If set, this is called with the current read position as we read data.
this.onprogress = options.onprogress;
}
async read(bytes)
{
let buffer = new ArrayBuffer(bytes);
let result = new Int8Array(buffer);
let output_pos = 0;
while(output_pos < bytes)
{
// See if we have leftover data in this.input_buffer.
if(this.input_buffer.byteLength > 0)
{
// Create a view of the bytes we want to copy, then use set() to copy them to the
// output. This is just memcpy(), why can't you just set(buf, srcPos, srcLen, dstPos)?
let copy_bytes = Math.min(bytes-output_pos, this.input_buffer.byteLength);
let buf = new Int8Array(this.input_buffer.buffer, this.input_buffer.byteOffset, copy_bytes);
result.set(buf, output_pos);
output_pos += copy_bytes;
// Remove the data we read from the buffer. This is just making the view smaller.
this.input_buffer = new Int8Array(this.input_buffer.buffer, this.input_buffer.byteOffset + copy_bytes);
continue;
}
// If we need more data and there isn't any, we've passed EOF.
if(this.input_buffer_finished)
throw new Error("Incomplete file");
let { value, done } = await this.reader.read();
if(value == null)
value = new Int8Array(0);
this.input_buffer_finished = done;
this.input_buffer = value;
if(value)
this.position += value.length;
if(this.onprogress)
this.onprogress(this.position);
};
return buffer;
}
};
// Download a ZIP, returning files as they download in the order they're stored
// in the ZIP.
ppixiv.ZipImageDownloader = class
{
constructor(url, options={})
{
this.url = url;
// An optional AbortSignal.
this.signal = options.signal;
this.onprogress = options.onprogress;
this.start_promise = this.start();
}
async start()
{
let response = await helpers.send_pixiv_request({
method: "GET",
url: this.url,
responseType: "arraybuffer",
signal: this.signal,
});
// We could also figure out progress from frame numbers, but doing it with the actual
// amount downloaded is more accurate, and the server always gives us content-length.
this.total_length = response.headers.get("Content-Length");
if(this.total_length != null)
this.total_length = parseInt(this.total_length);
// Firefox is in the dark ages and can't stream data from fetch. Fall back
// on loading the whole body if we don't have getReader.
let fetch_reader;
if(response.body.getReader)
fetch_reader = response.body.getReader();
else
fetch_reader = await response.arrayBuffer();
this.reader = new IncrementalReader(fetch_reader, {
onprogress: (position) => {
if(this.onprogress && this.total_length > 0)
{
let progress = position / this.total_length;
this.onprogress(progress);
}
}
});
}
async get_next_frame()
{
// Wait for start_download to complete, if it hasn't yet.
await this.start_promise;
// Read the local file header up to the filename.
let header = await this.reader.read(30);
let view = new DataView(header);
// Check the header.
let magic = view.getUint32(0, true);
if(magic == 0x02014b50)
{
// Once we see the central directory, we're at the end.
return null;
}
if(magic != 0x04034b50)
throw Error("Unrecognized file");
let compression = view.getUint16(8, true);
if(compression != 0)
throw Error("Unsupported compression method");
// Get the variable field lengths, and skip over the rest of the local file headers.
let file_size = view.getUint32(22, true);
let filename_size = view.getUint16(26, true);
let extra_size = view.getUint16(28, true);
await this.reader.read(filename_size);
await this.reader.read(extra_size);
// Read the file.
return await this.reader.read(file_size);
}
};
ppixiv.ZipImagePlayer = class
{
constructor(options)
{
this.next_frame = this.next_frame.bind(this);
this.op = options;
// If true, continue playback when we get more data.
this.waiting_for_frame = true;
this.dead = false;
this.context = options.canvas.getContext("2d");
this.frame_count = this.op.metadata.frames.length;
// The frame that we want to be displaying:
this.frame = 0;
this.failed = false;
// Make a list of timestamps for each frame.
this.frameTimestamps = [];
let milliseconds = 0;
let last_frame_time = 0;
for(let frame of this.op.metadata.frames)
{
this.frameTimestamps.push(milliseconds);
milliseconds += frame.delay;
last_frame_time = frame.delay;
}
this.total_length = milliseconds;
// The duration to display on the seek bar. This doesn't include the duration of the
// final frame. We can't seek to the actual end of the video past the end of the last
// frame, and the end of the seek bar represents the beginning of the last frame.
this.seekable_length = milliseconds - last_frame_time;
this.frame_data = [];
this.frame_images = [];
this.speed = 1;
this.paused = !this.op.autoStart;
this.load();
}
error(msg)
{
this.failed = true;
throw Error("ZipImagePlayer error: " + msg);
}
async load()
{
this.downloader = new ZipImageDownloader(this.op.source, {
signal: this.op.signal,
});
let frame = 0;
while(1)
{
let file;
try {
file = await this.downloader.get_next_frame();
} catch(e) {
// This will usually be cancellation.
console.info("Error downloading file", e);
return;
}
if(file == null)
break;
// Read the frame data into a blob and store it.
//
// Don't decode it just yet. We'll decode it the first time it's displayed. This way,
// we read the file as it comes in, but we won't burst decode every frame right at the
// start. This is important if the video ZIP is coming out of cache, since the browser
// can't cache the image decodes and we'll cause a big burst of CPU load.
let mime_type = this.op.metadata.mime_type || "image/png";
let blob = new Blob([file], {type: mime_type});
this.frame_data.push(blob);
// Call progress. This is relative to frame timestamps, so load progress lines up
// with the seek bar.
if(this.op.progress)
{
let progress = this.frameTimestamps[frame] / this.total_length;
this.op.progress(progress);
}
frame++;
// We have more data to potentially decode, so start decode_frames if it's not already running.
this.decode_frames();
}
// Call completion.
if(this.op.progress)
this.op.progress(null);
}
// Load the next frame into this.frame_images.
async decode_frames()
{
// If this is already running, don't start another.
if(this.loading_frames)
return;
try {
this.loading_frames = true;
while(await this.decode_one_frame())
{
}
} finally {
this.loading_frames = false;
}
}
// Decode up to one frame ahead of this.frame, so we don't wait until we need a
// frame to start decoding it. Return true if we decoded a frame and should be
// called again to see if we can decode another.
async decode_one_frame()
{
let ahead = 0;
for(ahead = 0; ahead < 2; ++ahead)
{
let frame = this.frame + ahead;
// Stop if we don't have data for this frame. If we don't have this frame, we won't
// have any after either.
let blob = this.frame_data[frame];
if(blob == null)
return;
// Skip this frame if it's already decoded.
if(this.frame_images[frame])
continue;
let url = URL.createObjectURL(blob);
let image = document.createElement("img");
image.src = url;
await helpers.wait_for_image_load(image);
URL.revokeObjectURL(url);
this.frame_images[frame] = image;
// If we were stalled waiting for data, display the frame. It's possible the frame
// changed while we were blocking and we won't actually have the new frame, but we'll
// just notice and turn waiting_for_frame back on.
if(this.waiting_for_frame)
{
this.waiting_for_frame = false;
this.display_frame();
}
if(this.dead)
return false;
return true;
}
return false;
}
async display_frame()
{
if(this.dead)
return;
this.decode_frames();
// If we don't have the frame yet, just record that we want to be called when the
// frame is decoded and stop. decode_frames will call us when there's a frame to display.
if(!this.frame_images[this.frame])
{
// We haven't downloaded this far yet. Show the frame when we get it.
this.waiting_for_frame = true;
return;
}
let image = this.frame_images[this.frame];
if(this.op.autosize) {
if(this.context.canvas.width != image.width || this.context.canvas.height != image.height) {
// make the canvas autosize itself according to the images drawn on it
// should set it once, since we don't have variable sized frames
this.context.canvas.width = image.width;
this.context.canvas.height = image.height;
}
};
this.drawn_frame = this.frame;
this.context.clearRect(0, 0, this.op.canvas.width,
this.op.canvas.height);
this.context.drawImage(image, 0, 0);
// If the user wants to know when the frame is ready, call it.
if(this.op.drew_frame)
{
helpers.yield(() => {
this.op.drew_frame(null);
});
}
if(this.paused)
return;
let meta = this.op.metadata.frames[this.frame];
this.pending_frame_metadata = meta;
this.refresh_timer();
}
unset_timer()
{
if(!this.timer)
return;
clearTimeout(this.timer);
this.timer = null;
}
refresh_timer()
{
if(this.paused)
return;
this.unset_timer();
this.timer = setTimeout(this.next_frame, this.pending_frame_metadata.delay / this.speed);
}
get_frame_duration()
{
let meta = this.op.metadata.frames[this.frame];
return meta.delay;
}
next_frame(frame)
{
this.timer = null;
if(this.frame >= (this.frame_count - 1)) {
if(!this.op.loop) {
this.pause();
return;
}
this.frame = 0;
} else {
this.frame += 1;
}
this.display_frame();
}
play()
{
if(this.dead)
return;
if(this.paused) {
this.paused = false;
this.display_frame();
}
}
pause()
{
if(this.dead)
return;
if(!this.paused) {
this.unset_timer();
this.paused = true;
}
}
toggle_pause()
{
if(this.paused)
this.play();
else
this.pause();
}
rewind()
{
if(this.dead)
return;
this.frame = 0;
this.unset_timer();
this.display_frame();
}
set_speed(value)
{
this.speed = value;
// Refresh the timer, so we don't wait a long time if we're changing from a very slow
// playback speed.
this.refresh_timer();
}
stop()
{
this.dead = true;
this.unset_timer();
this.frame_images = null;
}
get_current_frame()
{
return this.frame;
}
set_current_frame(frame)
{
frame %= this.frame_count;
if(frame < 0)
frame += this.frame_count;
this.frame = frame;
this.display_frame();
}
get_total_duration()
{
return this.total_length / 1000;
}
get_seekable_duration()
{
return this.seekable_length / 1000;
}
get_current_frame_time()
{
return this.frameTimestamps[this.frame] / 1000;
}
// Set the video to the closest frame to the given time.
set_current_frame_time(seconds)
{
// We don't actually need to check all frames, but there's no need to optimize this.
let closest_frame = null;
let closest_error = null;
for(let frame = 0; frame < this.op.metadata.frames.length; ++frame)
{
// Only seek to images that we've downloaded. If we reach a frame we don't have
// yet, stop.
if(!this.frame_data[frame])
break;
let error = Math.abs(seconds - this.frameTimestamps[frame]/1000);
if(closest_frame == null || error < closest_error)
{
closest_frame = frame;
closest_error = error;
}
}
this.frame = closest_frame;
this.display_frame();
}
get_frame_count() { return this.frame_count; }
}
/*
* The MIT License (MIT)
*
* Copyright (c) 2014 Pixiv Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/zip_image_player.js
`;
ppixiv.resources["src/screen.js"] = `"use strict";
// The base class for our main screens.
ppixiv.screen = class
{
constructor(container)
{
this.container = container;
// Make our container focusable, so we can give it keyboard focus when we
// become active.
this.container.tabIndex = -1;
}
// Handle a key input. This is only called while the screen is active.
handle_onkeydown(e)
{
}
// Return the view that navigating back in the popup menu should go to.
get navigate_out_target() { return null; }
// If this screen is displaying an image, return its ID.
// If this screen is displaying a user's posts, return "user:ID".
// Otherwise, return null.
get displayed_illust_id()
{
return null;
}
// If this screen is displaying a manga page, return its ID. Otherwise, return null.
// If this is non-null, displayed_illust_id will always also be non-null.
get displayed_illust_page()
{
return null;
}
// These are called to restore the scroll position on navigation.
scroll_to_top() { }
restore_scroll_position() { }
scroll_to_illust_id(illust_id, manga_page) { }
async set_active(active)
{
// Show or hide the screen.
this.container.hidden = !active;
if(active)
{
// Focus the container, so it receives keyboard events, eg. home/end.
this.container.focus();
}
else
{
// When the screen isn't active, send viewhidden to close all popup menus inside it.
view_hidden_listener.send_viewhidden(this.container);
}
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/screen.js
`;
ppixiv.resources["src/screen_illust.js"] = `"use strict";
// The main UI. This handles creating the viewers and the global UI.
ppixiv.screen_illust = class extends ppixiv.screen
{
constructor(container)
{
super(container);
this.onwheel = this.onwheel.bind(this);
this.refresh_ui = this.refresh_ui.bind(this);
this.data_source_updated = this.data_source_updated.bind(this);
this.current_illust_id = -1;
this.latest_navigation_direction_down = true;
this.container = container;
this.progress_bar = main_controller.singleton.progress_bar;
// Create a UI box and put it in its container.
var ui_container = this.container.querySelector(".ui");
this.ui = new image_ui(ui_container, this.progress_bar);
var ui_box = this.container.querySelector(".ui-box");
var ui_visibility_changed = () => {
// Hide the dropdown tag widget when the hover UI is hidden.
let visible = ui_box.classList.contains("hovering-over-box") || ui_box.classList.contains("hovering-over-sphere");
if(!visible)
{
this.ui.bookmark_tag_widget.visible = false; // XXX remove
view_hidden_listener.send_viewhidden(ui_box);
}
// Tell the image UI when it's visible.
this.ui.visible = visible;
};
ui_box.addEventListener("mouseenter", (e) => { helpers.set_class(ui_box, "hovering-over-box", true); ui_visibility_changed(); });
ui_box.addEventListener("mouseleave", (e) => { helpers.set_class(ui_box, "hovering-over-box", false); ui_visibility_changed(); });
var hover_circle = this.container.querySelector(".ui .hover-circle");
hover_circle.addEventListener("mouseenter", (e) => { helpers.set_class(ui_box, "hovering-over-sphere", true); ui_visibility_changed(); });
hover_circle.addEventListener("mouseleave", (e) => { helpers.set_class(ui_box, "hovering-over-sphere", false); ui_visibility_changed(); });
image_data.singleton().user_modified_callbacks.register(this.refresh_ui);
image_data.singleton().illust_modified_callbacks.register(this.refresh_ui);
settings.register_change_callback("recent-bookmark-tags", this.refresh_ui);
// Remove the "flash" class when the page change indicator's animation finishes.
let page_change_indicator = this.container.querySelector(".page-change-indicator");
page_change_indicator.addEventListener("animationend", (e) => {
console.log("done", e.target);
page_change_indicator.classList.remove("flash");
});
new hide_mouse_cursor_on_idle(this.container.querySelector(".mouse-hidden-box"));
// this.manga_thumbnails = new manga_thumbnail_widget(this.container.querySelector(".manga-thumbnail-container"));
this.container.addEventListener("wheel", this.onwheel, { passive: false });
// A bar showing how far along in an image sequence we are:
this.manga_page_bar = new progress_bar(this.container.querySelector(".ui-box")).controller();
this.seek_bar = new seek_bar(this.container.querySelector(".ugoira-seek-bar"));
this.set_active(false, { });
this.flashed_page_change = false;
}
set_data_source(data_source)
{
if(data_source == this.data_source)
return;
if(this.data_source != null)
{
this.data_source.remove_update_listener(this.data_source_updated);
this.data_source = null;
}
this.data_source = data_source;
this.ui.data_source = data_source;
if(this.data_source != null)
{
this.data_source.add_update_listener(this.data_source_updated);
this.refresh_ui();
}
}
get _hide_image()
{
return this.container.querySelector(".image-container").hidden;
}
set _hide_image(value)
{
this.container.querySelector(".image-container").hidden = value;
}
async set_active(active, { illust_id, page, data_source, restore_history })
{
this._active = active;
await super.set_active(active);
// If we have a viewer, tell it if we're active.
if(this.viewer != null)
this.viewer.active = this._active;
if(!active)
{
this.cancel_async_navigation();
// Remove any image we're displaying, so if we show another image later, we
// won't show the previous image while the new one's data loads.
if(this.viewer != null)
this._hide_image = true;
// Stop showing the user in the context menu, and stop showing the current page.
main_context_menu.get.user_id = null;
main_context_menu.get.page = null;
this.flashed_page_change = false;
this.stop_displaying_image();
return;
}
this.set_data_source(data_source);
this.show_image(illust_id, page, restore_history);
}
// Show an image. If manga_page is -1, show the last page.
async show_image(illust_id, manga_page, restore_history)
{
helpers.set_class(document.body, "force-ui", unsafeWindow.debug_show_ui);
// Reset the manga page change indicator when we change images.
this.flashed_page_change = false;
// If we previously set a pending navigation, this navigation overrides it.
this.cancel_async_navigation();
// Remember that this is the image we want to be displaying.
this.wanted_illust_id = illust_id;
this.wanted_illust_page = manga_page;
// Get very basic illust info. This is enough to tell which viewer to use, how
// many pages it has, and whether it's muted. This will always complete immediately
// if we're coming from a search or anywhere else that will already have this info,
// but it can block if we're loading from scratch.
let early_illust_data = await image_data.singleton().get_early_illust_data(illust_id);
// If we were deactivated while waiting for image info or the image we want to show has changed, stop.
if(!this.active || this.wanted_illust_id != illust_id || this.wanted_illust_page != manga_page)
{
console.log("show_image: illust ID or page changed while async, stopping");
return;
}
// Check if we got illust info. This usually means it's been deleted.
if(early_illust_data == null)
{
let message = image_data.singleton().get_illust_load_error(illust_id);
message_widget.singleton.show(message);
message_widget.singleton.clear_timer();
return;
}
// If manga_page is -1, update wanted_illust_page with the last page now that we know
// what it is.
if(manga_page == -1)
manga_page = early_illust_data.pageCount - 1;
else
manga_page = helpers.clamp(manga_page, 0, early_illust_data.pageCount-1);
this.wanted_illust_page = manga_page;
// If this image is already loaded, just make sure it's not hidden.
if( this.wanted_illust_id == this.current_illust_id &&
this.wanted_illust_page == this.viewer.page &&
this.viewer != null &&
this.hiding_muted_image == this.view_muted && // view-muted not changing
!this._hide_image)
{
console.log(\`illust \${illust_id} page \${this.wanted_illust_page} is already displayed\`);
return;
}
console.log(\`Showing image \${illust_id} page \${manga_page}\`);
helpers.set_title_and_icon(early_illust_data);
// Tell the preloader about the current image.
image_preloader.singleton.set_current_image(illust_id, manga_page);
// If we adjusted the page, update the URL. Allow "page" to be 1 or not present for
// page 1.
var args = helpers.args.location;
var wanted_page_arg = early_illust_data.pageCount > 1? (manga_page + 1):1;
let current_page_arg = args.hash.get("page") || "1";
if(current_page_arg != wanted_page_arg)
{
if(wanted_page_arg != null)
args.hash.set("page", wanted_page_arg);
else
args.hash.delete("page");
console.log("Updating URL with page number:", args.hash.toString());
helpers.set_page_url(args, false /* add_to_history */);
}
// This is the first image we're displaying if we previously had no illust ID, or
// if we were hidden.
let is_first_image_displayed = this.current_illust_id == -1 || this._hide_image;
// Speculatively load the next image, which is what we'll show if you press page down, so
// advancing through images is smoother.
//
// We don't do this when showing the first image, since the most common case is simply
// viewing a single image and not navigating to any others, so this avoids making
// speculative loads every time you load a single illustration.
if(!is_first_image_displayed)
{
// get_navigation may block to load more search results. Run this async without
// waiting for it.
(async() => {
let { illust_id: new_illust_id, page: new_page } =
await this.get_navigation(this.latest_navigation_direction_down);
// Let image_preloader handle speculative loading. If preload_illust_id is null,
// we're telling it that we don't need to load anything.
image_preloader.singleton.set_speculative_image(new_illust_id, new_page);
})();
}
// If the illust ID isn't changing, just update the viewed page.
if(illust_id == this.current_illust_id && this.viewer != null && this.viewer.page != this.wanted_illust_page)
{
console.log("Image ID not changed, setting page", this.wanted_illust_page, "of image", this.current_illust_id);
this._hide_image = false;
this.viewer.page = this.wanted_illust_page;
if(this.manga_thumbnails)
this.manga_thumbnails.current_page_changed(manga_page);
this.refresh_ui();
return;
}
// Finalize the new illust ID.
this.current_illust_id = illust_id;
this.current_user_id = early_illust_data.userId;
this.viewing_manga = early_illust_data.pageCount > 1; // for navigate_out_target
this.ui.illust_id = illust_id;
this.refresh_ui();
if(this.update_mute(early_illust_data))
return;
// If the image has the ドット絵 tag, enable nearest neighbor filtering.
helpers.set_class(document.body, "dot", helpers.tags_contain_dot(early_illust_data));
// Dismiss any message when changing images.
message_widget.singleton.hide();
this.remove_viewer();
// Create the image viewer.
var progress_bar = this.progress_bar.controller();
let image_container = this.container.querySelector(".image-container");
if(early_illust_data.illustType == 2)
this.viewer = new viewer_ugoira(image_container, illust_id, {
progress_bar: progress_bar,
seek_bar: this.seek_bar,
});
else
{
this.viewer = new viewer_images(image_container, illust_id, {
progress_bar: progress_bar,
manga_page_bar: this.manga_page_bar,
manga_page: manga_page,
restore_history: restore_history,
});
}
// If the viewer was hidden, unhide it now that the new one is set up.
this._hide_image = false;
this.viewer.active = this._active;
// Refresh the UI now that we have a new viewer.
this.refresh_ui();
}
get view_muted()
{
return helpers.args.location.hash.get("view-muted") == "1";
}
should_hide_muted_image(early_illust_data)
{
let muted_tag = muting.singleton.any_tag_muted(early_illust_data.tags);
let muted_user = muting.singleton.is_muted_user_id(early_illust_data.userId);
if(this.view_muted || (!muted_tag && !muted_user))
return { is_muted: false };
return { is_muted: true, muted_tag: muted_tag, muted_user: muted_user };
}
update_mute(early_illust_data)
{
// Check if this post is muted.
let { is_muted } = this.should_hide_muted_image(early_illust_data);
this.hiding_muted_image = this.view_muted;
if(!is_muted)
return false;
// Tell the thumbnail view about the image. If the image is muted, disable thumbs.
if(this.manga_thumbnails)
this.manga_thumbnails.set_illust_info(null);
// If the image is muted, load a dummy viewer.
let image_container = this.container.querySelector(".image-container");
this.remove_viewer();
this.viewer = new viewer_muted(image_container, this.current_illust_id);
this._hide_image = false;
return true;
}
// Remove the old viewer, if any.
remove_viewer()
{
if(this.viewer != null)
{
this.viewer.shutdown();
this.viewer = null;
}
}
// If we started navigating to a new image and were delayed to load data (either to load
// the image or to load a new page), cancel it and stay where we are.
cancel_async_navigation()
{
// If we previously set a pending navigation, this navigation overrides it.
if(this.pending_navigation == null)
return;
console.info("Cancelling async navigation");
this.pending_navigation = null;
}
// Stop displaying any image (and cancel any wanted navigation), putting us back
// to where we were before displaying any images.
//
// This will also prevent the next image displayed from triggering speculative
// loading, which we don't want to do when clicking an image in the thumbnail
// view.
stop_displaying_image()
{
if(this.viewer != null)
{
this.viewer.shutdown();
this.viewer = null;
}
if(this.manga_thumbnails)
this.manga_thumbnails.set_illust_info(null);
this.wanted_illust_id = null;
this.current_illust_id = null;
this.wanted_illust_page = 0;
this.current_illust_id = -1;
this.refresh_ui();
}
data_source_updated()
{
this.refresh_ui();
}
get active()
{
return this._active;
}
// Refresh the UI for the current image.
refresh_ui()
{
// Don't refresh if the thumbnail view is active. We're not visible, and we'll just
// step over its page title, etc.
if(!this._active)
return;
// Tell the UI which page is being viewed.
var page = this.viewer != null? this.viewer.page:0;
this.ui.set_displayed_page_info(page);
// Tell the context menu which user is being viewed.
main_context_menu.get.user_id = this.current_user_id;
main_context_menu.get.page = page;
// Pull out info about the user and illustration.
var illust_id = this.current_illust_id;
// Update the disable UI button to point at the current image's illustration page.
var disable_button = this.container.querySelector(".disable-ui-button");
disable_button.href = "/artworks/" + illust_id + "#no-ppixiv";
// If we're not showing an image yet, hide the UI and don't try to update it.
helpers.set_class(this.container.querySelector(".ui"), "disabled", illust_id == -1);
if(illust_id == -1)
return;
this.ui.refresh();
}
onwheel(e)
{
if(!this._active)
return;
// Don't intercept wheel scrolling over the description box.
if(e.target.closest(".description") != null)
return;
var down = e.deltaY > 0;
this.move(down, e.shiftKey /* skip_manga_pages */);
}
get displayed_illust_id()
{
return this.wanted_illust_id;
}
get displayed_illust_page()
{
return this.wanted_illust_page;
}
get navigate_out_target()
{
// If we're viewing a manga post, exit to the manga page view instead of the search.
if(this.viewing_manga)
return "manga";
else
return "search";
}
handle_onkeydown(e)
{
// Let the viewer handle the input first.
if(this.viewer && this.viewer.onkeydown)
{
this.viewer.onkeydown(e);
if(e.defaultPrevented)
return;
}
this.ui.handle_onkeydown(e);
if(e.defaultPrevented)
return;
if(e.ctrlKey || e.altKey || e.metaKey)
return;
switch(e.keyCode)
{
case 37: // left
case 38: // up
case 33: // pgup
e.preventDefault();
e.stopPropagation();
this.move(false, e.shiftKey /* skip_manga_pages */);
break;
case 39: // right
case 40: // down
case 34: // pgdn
e.preventDefault();
e.stopPropagation();
this.move(true, e.shiftKey /* skip_manga_pages */);
break;
}
}
// Get the illust_id and page navigating down (or up) will go to.
//
// This may trigger loading the next page of search results, if we've reached the end.
async get_navigation(down, { skip_manga_pages=false }={})
{
// Check if we're just changing pages within the same manga post.
let leaving_manga_post = false;
if(!skip_manga_pages && this.wanted_illust_id != null)
{
// Using early_illust_data here means we can handle page navigation earlier, if
// the user navigates before we have full illust info.
let early_illust_data = await image_data.singleton().get_early_illust_data(this.wanted_illust_id);
let num_pages = early_illust_data.pageCount;
if(num_pages > 1)
{
var old_page = this.displayed_illust_page;
var new_page = old_page + (down? +1:-1);
new_page = Math.max(0, Math.min(num_pages - 1, new_page));
if(new_page != old_page)
return { illust_id: this.wanted_illust_id, page: new_page };
// If the page didn't change, we reached the end of the manga post.
leaving_manga_post = true;
}
}
// If we have a target illust_id, move relative to it. Otherwise, move relative to the
// displayed image. This way, if we navigate repeatedly before a previous navigation
// finishes, we'll keep moving rather than waiting for each navigation to complete.
var navigate_from_illust_id = this.wanted_illust_id;
if(navigate_from_illust_id == null)
navigate_from_illust_id = this.current_illust_id;
// Get the next (or previous) illustration after the current one. This will be null if we've
// reached the end of the list, or if it requires loading the next page of search results.
var new_illust_id = this.data_source.id_list.get_neighboring_illust_id(navigate_from_illust_id, down);
if(new_illust_id == null)
{
// We didn't have the new illustration, so we may need to load another page of search results.
// Find the page the current illustration is on.
let next_page = this.data_source.id_list.get_page_for_neighboring_illust(navigate_from_illust_id, down);
// If we can't find the next page, then the current image isn't actually loaded in
// the current search results. This can happen if the page is reloaded: we'll show
// the previous image, but we won't have the results loaded (and the results may have
// changed). Just jump to the first image in the results so we get back to a place
// we can navigate from.
//
// Note that we use id_list.get_first_id rather than get_current_illust_id, which is
// just the image we're already on.
if(next_page == null)
{
// We should normally know which page the illustration we're currently viewing is on.
console.warn("Don't know the next page for illust", navigate_from_illust_id);
new_illust_id = this.data_source.id_list.get_first_id();
if(new_illust_id != null)
return { illust_id: new_illust_id };
return { };
}
console.log("Loaded the next page of results:", next_page);
// The page shouldn't already be loaded. Double-check to help prevent bugs that might
// spam the server requesting the same page over and over.
if(this.data_source.id_list.is_page_loaded(next_page))
{
console.error("Page", next_page, "is already loaded");
return { };
}
// Ask the data source to load it.
let new_page_loaded = this.data_source.load_page(next_page, { cause: "illust navigation" });
// Wait for results.
new_page_loaded = await new_page_loaded;
this.pending_navigation = null;
if(new_page_loaded)
{
// Now that we've loaded data, try to find the new image again.
new_illust_id = this.data_source.id_list.get_neighboring_illust_id(navigate_from_illust_id, down);
}
console.log("Retrying navigation after data load");
}
// If we didn't get a page, we're at the end of the search results. Flash the
// indicator to show we've reached the end and stop.
if(new_illust_id == null)
{
console.log("Reached the end of the list");
this.flash_end_indicator(down, "last-image");
return { illust_id: null, page: null, end: true };
}
let page = down || skip_manga_pages? 0:-1;
return { illust_id: new_illust_id, page: page, leaving_manga_post: leaving_manga_post };
}
// Navigate to the next or previous image.
//
// If skip_manga_pages is true, jump past any manga pages in the current illustration. If
// this is true and we're navigating backwards, we'll also jump to the first manga page
// instead of the last.
async move(down, skip_manga_pages)
{
// Remember whether we're navigating forwards or backwards, for preloading.
this.latest_navigation_direction_down = down;
this.cancel_async_navigation();
let pending_navigation = this.pending_navigation = new Object();
// See if we should change the manga page. This may block if it needs to load
// the next page of search results.
let { illust_id: new_illust_id, page, end, leaving_manga_post } = await this.get_navigation(down, {
skip_manga_pages: skip_manga_pages,
});
// If this.pending_navigation is no longer the same as pending_navigation, we navigated since
// we requested this load and this navigation is stale, so stop.
if(this.pending_navigation != pending_navigation)
{
console.error("Aborting stale navigation");
return { stale: true };
}
// If we didn't get a page, we're at the end of the search results. Flash the
// indicator to show we've reached the end and stop.
if(end)
{
console.log("Reached the end of the list");
this.flash_end_indicator(down, "last-image");
return;
}
// If we're confirming leaving a manga post, do that now. This is done after we load the
// new page of search results if needed, so we know whether we've actually reached the end
// and should show the end indicator above instead.
if(leaving_manga_post && !this.flashed_page_change && 0)
{
this.flashed_page_change = true;
this.flash_end_indicator(down, "last-page");
// Start preloading the next image, so we load faster if the user scrolls again to go
// to the next image.
if(new_illust_id != null)
image_data.singleton().get_image_info(new_illust_id);
return;
}
// Go to the new illustration if we have one.
if(new_illust_id != null)
main_controller.singleton.show_illust(new_illust_id, { page: page });
}
flash_end_indicator(down, icon)
{
let indicator = this.container.querySelector(".page-change-indicator");
indicator.dataset.icon = icon;
indicator.dataset.side = down? "right":"left";
indicator.classList.remove("flash");
// Call getAnimations() so the animation is removed immediately:
indicator.getAnimations();
indicator.classList.add("flash");
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/screen_illust.js
`;
ppixiv.resources["src/screen_search.js"] = `"use strict";
// The search UI.
ppixiv.screen_search = class extends ppixiv.screen
{
constructor(container)
{
super(container);
this.thumbs_loaded = this.thumbs_loaded.bind(this);
this.data_source_updated = this.data_source_updated.bind(this);
this.onwheel = this.onwheel.bind(this);
// this.onmousemove = this.onmousemove.bind(this);
this.refresh_thumbnail = this.refresh_thumbnail.bind(this);
this.refresh_images = this.refresh_images.bind(this);
this.update_from_settings = this.update_from_settings.bind(this);
this.thumbnail_onclick = this.thumbnail_onclick.bind(this);
this.submit_user_search = this.submit_user_search.bind(this);
this.set_active(false, { });
this.thumbnail_templates = {};
window.addEventListener("thumbnailsLoaded", this.thumbs_loaded);
window.addEventListener("focus", this.visible_thumbs_changed);
this.container.addEventListener("wheel", this.onwheel, { passive: false });
// this.container.addEventListener("mousemove", this.onmousemove);
image_data.singleton().user_modified_callbacks.register(this.refresh_ui.bind(this));
// When a bookmark is modified, refresh the heart icon.
image_data.singleton().illust_modified_callbacks.register(this.refresh_thumbnail);
this.thumbnail_dimensions_style = helpers.create_style("");
document.body.appendChild(this.thumbnail_dimensions_style);
// Create the avatar widget shown on the artist data source.
this.avatar_widget = new avatar_widget({
parent: this.container.querySelector(".avatar-container"),
changed_callback: this.data_source_updated,
big: true,
mode: "dropdown",
});
// Create the tag widget used by the search data source.
this.tag_widget = new tag_widget({
parent: this.container.querySelector(".related-tag-list"),
format_link: function(tag)
{
// The recommended tag links are already on the search page, and retain other
// search settings.
let url = page_manager.singleton().get_url_for_tag_search(tag, ppixiv.location);
url.searchParams.delete("p");
return url.toString();
}.bind(this),
});
// Don't scroll thumbnails when scrolling tag dropdowns.
// FIXME: This works on member-tags-box, but not reliably on search-tags-box, even though
// they seem like the same thing.
this.container.querySelector(".member-tags-box .post-tag-list").addEventListener("scroll", function(e) { e.stopPropagation(); }, true);
this.container.querySelector(".search-tags-box .related-tag-list").addEventListener("scroll", function(e) { e.stopPropagation(); }, true);
// Set up hover popups.
dropdown_menu_opener.create_handlers(this.container, [".navigation-menu-box", ".thumbnail-settings-menu-box", ".ages-box", ".popularity-box", ".type-box", ".search-mode-box", ".size-box", ".aspect-ratio-box", ".bookmarks-box", ".time-box", ".member-tags-box", ".search-tags-box"]);
// As an optimization, start loading image info on mousedown. We don't navigate until click,
// but this lets us start loading image info a bit earlier.
this.container.querySelector(".thumbnails").addEventListener("mousedown", async (e) => {
if(e.button != 0)
return;
// Don't do this when viewing followed users, since we'll be loading the user rather than the post.
if(this.data_source && this.data_source.search_mode == "users")
return;
var a = e.target.closest("a.thumbnail-link");
if(a == null)
return;
if(a.dataset.illustId == null)
return;
let illust_data = await image_data.singleton().get_image_info(a.dataset.illustId);
// This is a bit optimistic, but if we get a result before the user releases the mouse, start
// preloading the image. This would be more effective if we had the image URL in thumbnail data.
helpers.preload_images([illust_data.urls.original]);
}, true);
this.container.querySelector(".refresh-search-button").addEventListener("click", this.refresh_search.bind(this));
this.container.querySelector(".whats-new-button").addEventListener("click", this.whats_new.bind(this));
this.container.querySelector(".thumbnails").addEventListener("click", this.thumbnail_onclick);
// 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();
});
var settings_menu = this.container.querySelector(".settings-menu-box > .popup-menu-box");
menu_option.add_settings(settings_menu);
settings.register_change_callback("thumbnail-size", () => {
// refresh_images first to update thumbnail_dimensions_style.
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);
// Create the tag dropdown for the search page input.
new tag_search_box_widget(this.container.querySelector(".tag-search-box"));
// Create the tag dropdown for the search input in the menu dropdown.
new tag_search_box_widget(this.container.querySelector(".navigation-search-box"));
// Handle submitting searches on the user search page.
this.container.querySelector(".user-search-box .search-submit-button").addEventListener("click", this.submit_user_search);
helpers.input_handler(this.container.querySelector(".user-search-box input.search-users"), this.submit_user_search);
// 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_needed_thumb_data();
this.first_visible_thumbs_changed();
}, {
root: this.container,
threshold: 1,
}));
this.intersection_observers.push(new IntersectionObserver((entries) => {
for(let entry of entries)
helpers.set_dataset(entry.target.dataset, "nearby", entry.isIntersecting);
// Set up any thumbs that just came nearby, and see if we need to load more search results.
this.set_visible_thumbs();
this.load_needed_thumb_data();
}, {
root: this.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.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_needed_thumb_data();
this.refresh_whats_new_button();
}
// 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.
let first_thumb = this.container.querySelector(\`.thumbnails > [data-id][data-fully-on-screen]\`);
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.page == null)
return;
main_controller.singleton.temporarily_ignore_onpopstate = true;
try {
let args = helpers.args.location;
this.data_source.set_start_page(args, first_thumb.dataset.page);
helpers.set_page_url(args, false, "viewing-page");
} finally {
main_controller.singleton.temporarily_ignore_onpopstate = 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_illust_ids = [];
for(let element of this.container.querySelectorAll(\`.thumbnails > [data-id][data-visible]:not([data-special])\`))
{
let { type, id } = helpers.parse_id(element.dataset.id);
if(type != "illust")
continue;
visible_illust_ids.push(id);
}
ppixiv.recently_seen_illusts.get().add_illusts(visible_illust_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(document.body.querySelector(".whats-new-box"));
}
/* This scrolls the thumbnail when you hover over it. It's sort of neat, but it's pretty
* choppy, and doesn't transition smoothly when the mouse first hovers over the thumbnail,
* causing it to pop to a new location.
onmousemove(e)
{
var thumb = e.target.closest(".thumbnail-box a");
if(thumb == null)
return;
var bounds = thumb.getBoundingClientRect();
var x = e.clientX - bounds.left;
var y = e.clientY - bounds.top;
x = 100 * x / thumb.offsetWidth;
y = 100 * y / thumb.offsetHeight;
var img = thumb.querySelector("img.thumb");
img.style.objectPosition = x + "% " + y + "%";
}
*/
onwheel(e)
{
// Stop event propagation so we don't change images on any viewer underneath the thumbs.
e.stopPropagation();
};
initial_refresh_ui()
{
if(this.data_source != null)
{
var ui_box = this.container.querySelector(".thumbnail-ui-box");
this.data_source.initial_refresh_thumbnail_ui(ui_box, this);
}
}
set_data_source(data_source)
{
if(this.data_source == data_source)
return;
// Remove listeners from the old data source.
if(this.data_source != null)
this.data_source.remove_update_listener(this.data_source_updated);
// If the search mode is changing (eg. we're going from a list of illustrations to a list
// of users), remove thumbs so we recreate them. Otherwise, refresh_images will reuse them
// and they can be left on the wrong display type.
var old_search_mode = this.data_source? this.data_source.search_mode:"";
var new_search_mode = data_source? data_source.search_mode:"";
if(old_search_mode != new_search_mode)
{
var ul = this.container.querySelector(".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;
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_widget.visible = false;
// Listen to the data source loading new pages, so we can refresh the list.
this.data_source.add_update_listener(this.data_source_updated);
this.load_needed_thumb_data();
};
restore_scroll_position()
{
// If we saved a scroll position when navigating away from a data source earlier,
// restore it now. Only do this once.
if(this.data_source.thumbnail_view_scroll_pos != null)
{
this.container.scrollTop = this.data_source.thumbnail_view_scroll_pos;
delete this.data_source.thumbnail_view_scroll_pos;
}
else
this.scroll_to_top();
}
scroll_to_top()
{
this.container.scrollTop = 0;
}
refresh_ui()
{
if(!this.active)
return;
var element_displaying = this.container.querySelector(".displaying");
element_displaying.hidden = this.data_source.get_displaying_text == null;
if(this.data_source.get_displaying_text != null)
{
// get_displaying_text can either be a string or an element.
let text = this.data_source.get_displaying_text();
helpers.remove_elements(element_displaying);
if(typeof text == "string")
element_displaying.innerText = text;
else if(text instanceof HTMLElement)
{
helpers.remove_elements(element_displaying);
element_displaying.appendChild(text);
}
}
// Set up bookmark and following search links.
for(let link of this.container.querySelectorAll('.following-users-link[data-which="public"]'))
link.href = \`/users/\${window.global_data.user_id}/following#ppixiv\`;
for(let link of this.container.querySelectorAll('.following-users-link[data-which="private"]'))
link.href = \`/users/\${window.global_data.user_id}/following?rest=hide#ppixiv\`;
for(let link of this.container.querySelectorAll('.bookmarks-link[data-which="all"]'))
link.href = \`/users/\${window.global_data.user_id}/bookmarks/artworks#ppixiv\`;
for(let link of this.container.querySelectorAll('.bookmarks-link[data-which="public"]'))
link.href = \`/users/\${window.global_data.user_id}/bookmarks/artworks#ppixiv?show-all=0\`;
for(let link of this.container.querySelectorAll('.bookmarks-link[data-which="private"]'))
link.href = \`/users/\${window.global_data.user_id}/bookmarks/artworks?rest=hide#ppixiv?show-all=0\`;
helpers.set_page_title(this.data_source.page_title || "Loading...");
var ui_box = this.container.querySelector(".thumbnail-ui-box");
this.data_source.refresh_thumbnail_ui(ui_box, this);
this.refresh_ui_for_user_id();
};
// Return the user ID we're viewing, or null if we're not viewing anything specific to a user.
get viewing_user_id()
{
if(this.data_source == null)
return null;
return this.data_source.viewing_user_id;
}
// If the data source has an associated artist, return the "user:ID" for the user, so
// when we navigate back to an earlier search, pulse_thumbnail will know which user to
// flash.
get displayed_illust_id()
{
if(this.data_source == null)
return super.displayed_illust_id;
let user_id = this.data_source.viewing_user_id;
if(user_id != null)
return "user:" + user_id;
return super.displayed_illust_id;
}
// Call refresh_ui_for_user_info with the user_info for the user we're viewing,
// if the user ID has changed.
async refresh_ui_for_user_id()
{
// If we're viewing ourself (our own bookmarks page), hide the user-related UI.
var initial_user_id = this.viewing_user_id;
var user_id = initial_user_id == window.global_data.user_id? null:initial_user_id;
var user_info = await image_data.singleton().get_user_info_full(user_id);
// Stop if the user ID changed since we started this request, or if we're no longer active.
if(this.viewing_user_id != initial_user_id || !this.active)
return;
// Set the bookmarks link.
var bookmarks_link = this.container.querySelector(".bookmarks-link");
bookmarks_link.hidden = user_info == null;
if(user_info != null)
{
var bookmarks_url = \`/users/\${user_info.userId}/bookmarks/artworks#ppixiv\`;
bookmarks_link.href = bookmarks_url;
bookmarks_link.dataset.popup = user_info? (\`View \${user_info.name}'s bookmarks\`):"View bookmarks";
}
// Set the similar artists link.
var similar_artists_link = this.container.querySelector(".similar-artists-link");
similar_artists_link.hidden = user_info == null;
if(user_info)
similar_artists_link.href = "/discovery/users#ppixiv?user_id=" + user_info.userId;
// Set the following link.
var following_link = this.container.querySelector(".following-link");
following_link.hidden = user_info == null;
if(user_info != null)
{
let following_url = "/users/" + user_info.userId + "/following#ppixiv";
following_link.href = following_url;
following_link.dataset.popup = user_info? ("View " + user_info.name + "'s followed users"):"View following";
}
let extra_links = [];
// Set the webpage link.
//
// If the webpage link is on a known site, disable the webpage link and add this to the
// generic links list, so it'll use the specialized icon.
var webpage_url = user_info && user_info.webpage;
if(webpage_url != null && this.find_link_image_type(webpage_url))
{
extra_links.push(webpage_url);
webpage_url = null;
}
var webpage_link = this.container.querySelector(".webpage-link");
webpage_link.hidden = webpage_url == null;
if(webpage_url != null)
{
webpage_link.href = webpage_url;
webpage_link.dataset.popup = webpage_url;
}
// Set the circle.ms link.
var circlems_url = user_info && user_info.social && user_info.social.circlems && user_info.social.circlems.url;
var circlems_link = this.container.querySelector(".circlems-icon");
circlems_link.hidden = circlems_url == null;
if(circlems_url != null)
circlems_link.href = circlems_url;
// Set the twitter link.
var twitter_url = user_info && user_info.social && user_info.social.twitter && user_info.social.twitter.url;
var twitter_link = this.container.querySelector(".twitter-icon");
twitter_link.hidden = twitter_url == null;
if(twitter_url != null)
{
twitter_link.href = twitter_url;
var path = new URL(twitter_url).pathname;
var parts = path.split("/");
twitter_link.dataset.popup = parts.length > 1? ("@" + parts[1]):"Twitter";
}
// Set the pawoo link.
var pawoo_url = user_info && user_info.social && user_info.social.pawoo && user_info.social.pawoo.url;
var pawoo_link = this.container.querySelector(".pawoo-icon");
pawoo_link.hidden = pawoo_url == null;
if(pawoo_url != null)
pawoo_link.href = pawoo_url;
// Set the "send a message" link.
var contact_link = this.container.querySelector(".contact-link");
contact_link.hidden = user_info == null;
if(user_info != null)
contact_link.href = "/messages.php?receiver_id=" + user_info.userId;
// Remove any extra buttons that we added earlier.
let row = this.container.querySelector(".button-row");
for(let div of row.querySelectorAll(".extra-profile-link-button"))
div.remove();
// Find any other links in the user's profile text.
if(user_info != null)
{
let div = document.createElement("div");
div.innerHTML = user_info.commentHtml;
for(let link of div.querySelectorAll("a"))
extra_links.push(helpers.fix_pixiv_link(link.href));
}
// Let the data source add more links.
if(this.data_source != null)
this.data_source.add_extra_links(extra_links);
let count = 0;
for(let url of extra_links)
{
url = new URL(url);
let entry = helpers.create_from_template(".template-extra-profile-link-button");
let a = entry.querySelector(".extra-link");
a.href = url;
a.dataset.popup = a.href;
let link_type = this.find_link_image_type(url);
if(link_type != null)
{
entry.querySelector(".default-icon").hidden = true;
entry.querySelector(link_type).hidden = false;
}
// Put these at the beginning, so they don't change the positioning of the other
// icons.
row.insertBefore(entry, row.querySelector(".first-icon"));
count++;
// Limit this in case people are putting a million links in their profiles.
if(count == 4)
break;
}
// Tell the context menu which user is being viewed (if we're viewing a user-specific
// search).
main_context_menu.get.user_id = user_id;
}
// Use different icons for sites where you can give the artist money. This helps make
// the string of icons more meaningful (some artists have a lot of them).
find_link_image_type(url)
{
url = new URL(url);
let alt_icons = {
".shopping-cart": [
"dlsite.com",
"fanbox.cc",
"fantia.jp",
"skeb.jp",
"ko-fi.com",
"dmm.co.jp",
]
};
// Special case for old Fanbox URLs that were under the Pixiv domain.
if((url.hostname == "pixiv.net" || url.hostname == "www.pixiv.net") && url.pathname.startsWith("/fanbox/"))
return ".shopping-cart";
for(let alt in alt_icons)
{
// "domain.com" matches domain.com and *.domain.com.
for(let domain of alt_icons[alt])
{
if(url.hostname == domain)
return alt;
if(url.hostname.endsWith("." + domain))
return alt;
}
}
return null;
};
async set_active(active, { data_source })
{
if(this._active == active && this.data_source == data_source)
return;
let was_active = this._active;
this._active = active;
// We're either becoming active or inactive, or our data source is being changed.
// Store our scroll position on the data source, so we can restore it if it's
// reactivated. There's only one instance of thumbnail_view, so this is safe.
// Only do this if we were previously active, or we're hidden and scrollTop may
// be 0.
if(was_active && this.data_source)
this.data_source.thumbnail_view_scroll_pos = this.container.scrollTop;
await super.set_active(active);
if(active)
{
this.set_data_source(data_source);
this.initial_refresh_ui();
this.refresh_ui();
// Refresh the images now, so it's possible to scroll to entries, but wait to start
// loading data to give the caller a chance to call scroll_to_illust_id(), which needs
// to happen after refresh_images but before load_needed_thumb_data. This way, if
// we're showing a page far from the top, we won't load the first page that we're about
// to scroll away from.
this.refresh_images();
helpers.yield(() => {
this.load_needed_thumb_data();
});
}
else
{
this.stop_pulsing_thumbnail();
main_context_menu.get.user_id = null;
}
}
get active()
{
return this._active;
}
data_source_updated()
{
this.refresh_images();
this.load_needed_thumb_data();
this.refresh_ui();
}
// Recreate thumbnail images (the actual
elements).
//
// This is done when new pages are loaded, to create the correct number of images.
// We don't need to do this when scrolling around or when new thumbnail data is available.
refresh_images()
{
// Make a list of [illust_id, page] thumbs to add.
let images_to_add = [];
if(this.data_source != null)
{
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();
let items_per_page = this.data_source.estimated_items_per_page;
for(let page = min_page; page <= max_page; ++page)
{
let illust_ids = id_list.illust_ids_by_page.get(page);
if(illust_ids == null)
{
// This page isn't loaded. Fill the gap with items_per_page blank entries.
for(let idx = 0; idx < items_per_page; ++idx)
images_to_add.push([null, page]);
continue;
}
// Create an image for each ID.
for(let illust_id of illust_ids)
images_to_add.push({id: illust_id, page: page});
}
// If this data source supports a start page and we started after page 1, add the "load more"
// button at the beginning.
//
// The page number for this button is the same as the thumbs that follow it, not the
// page it'll load if clicked, so scrolling to it doesn't make us think we're scrolled
// to that page.
if(this.data_source.initial_page > 1)
images_to_add.splice(0, 0, { id: "special:previous-page", page: this.data_source.initial_page });
}
// Add thumbs.
//
// Most of the time we're just adding thumbs to the list. Avoid removing or recreating
// thumbs that aren't actually changing, which reduces flicker.
//
// Do this by looking for a range of thumbnails that matches a range in images_to_add.
// If we're going to display [0,1,2,3,4,5,6,7,8,9], and the current thumbs are [4,5,6],
// then 4,5,6 matches and can be reused. We'll add [0,1,2,3] to the beginning and [7,8,9]
// to the end.
//
// Most of the time we're just appending. The main time that we add to the beginning is
// the "load previous results" button.
let ul = this.container.querySelector(".thumbnails");
let next_node = ul.firstElementChild;
// Make a dictionary of all illust IDs and pages, so we can look them up quickly.
let images_to_add_index = {};
for(let i = 0; i < images_to_add.length; ++i)
{
let entry = images_to_add[i];
let illust_id = entry.id;
let page = entry.page;
let index = illust_id + "/" + page;
images_to_add_index[index] = i;
}
let get_node_idx = function(node)
{
if(node == null)
return null;
let illust_id = node.dataset.id;
let page = node.dataset.page;
let index = illust_id + "/" + page;
return images_to_add_index[index];
}
// Find the first match (4 in the above example).
let first_matching_node = next_node;
while(first_matching_node && get_node_idx(first_matching_node) == null)
first_matching_node = first_matching_node.nextElementSibling;
// If we have a first_matching_node, walk forward to find the last matching node (6 in
// the above example).
let last_matching_node = first_matching_node;
if(last_matching_node != null)
{
// Make sure the range is contiguous. first_matching_node and all nodes through last_matching_node
// should match a range exactly. If there are any missing entries, stop.
let next_expected_idx = get_node_idx(last_matching_node) + 1;
while(last_matching_node && get_node_idx(last_matching_node.nextElementSibling) == next_expected_idx)
{
last_matching_node = last_matching_node.nextElementSibling;
next_expected_idx++;
}
}
// If we have a matching range, save the scroll position relative to it, so if we add
// new elements at the top, we stay scrolled where we are. Otherwise, just restore the
// current scroll position.
let save_scroll = new SaveScrollPosition(this.container);
if(first_matching_node)
save_scroll.save_relative_to(first_matching_node);
// If we have a range, delete all items outside of it. Otherwise, just delete everything.
while(first_matching_node && first_matching_node.previousElementSibling)
first_matching_node.previousElementSibling.remove();
while(last_matching_node && last_matching_node.nextElementSibling)
last_matching_node.nextElementSibling.remove();
if(!first_matching_node && !last_matching_node)
helpers.remove_elements(ul);
// If we have a matching range, add any new elements before it.
if(first_matching_node)
{
let first_idx = get_node_idx(first_matching_node);
for(let idx = first_idx - 1; idx >= 0; --idx)
{
let entry = images_to_add[idx];
let illust_id = entry.id;
let page = entry.page;
let node = this.create_thumb(illust_id, page);
first_matching_node.insertAdjacentElement("beforebegin", node);
first_matching_node = node;
}
}
// Add any new elements after the range. If we don't have a range, just add everything.
let last_idx = -1;
if(last_matching_node)
last_idx = get_node_idx(last_matching_node);
for(let idx = last_idx + 1; idx < images_to_add.length; ++idx)
{
let entry = images_to_add[idx];
let illust_id = entry.id;
let page = entry.page;
let node = this.create_thumb(illust_id, page);
ul.appendChild(node);
}
if(this.container.offsetWidth == 0)
return;
let thumbnail_size = settings.get("thumbnail-size", 4);
thumbnail_size = thumbnail_size_slider_widget.thumbnail_size_for_value(thumbnail_size);
this.thumbnail_dimensions_style.textContent = helpers.make_thumbnail_sizing_style(ul, ".screen-search-container", {
wide: true,
size: thumbnail_size,
max_columns: 5,
// Set a minimum padding to make sure there's room for the popup text to fit between images.
min_padding: 15,
});
// Restore the value of scrollTop from before we updated. For some reason, Firefox
// modifies scrollTop after we add a bunch of items, which causes us to scroll to
// the wrong position, even though scrollRestoration is disabled.
save_scroll.restore();
}
// Start loading data pages that we need to display visible thumbs, and start
// loading thumbnail data for nearby thumbs.
async load_needed_thumb_data()
{
// elements is a list of elements that are onscreen (or close to being onscreen).
// We want thumbnails loaded for these, even if we need to load more thumbnail data.
//
// nearby_elements is a list of elements that are a bit further out. If we load
// thumbnail data for elements, we'll load these instead. That way, if we scroll
// up a bit and two more thumbs become visible, we'll load a bigger chunk.
// That way, we make fewer batch requests instead of requesting two or three
// thumbs at a time.
// Make a list of pages that we need loaded, and illustrations that we want to have
// set.
var wanted_illust_ids = [];
let elements = this.get_visible_thumbnails();
for(var element of elements)
{
if(element.dataset.id != null)
{
// If this is an illustration, add it to wanted_illust_ids so we load its thumbnail
// info. Don't do this if it's a user.
if(helpers.parse_id(element.dataset.id).type == "illust")
wanted_illust_ids.push(element.dataset.id);
}
}
// We load pages when the last thumbs on the previous page are loaded, but the first
// time through there's no previous page to reach the end of. Always make sure the
// first page is loaded (usually page 1).
let load_page = null;
let first_page = this.data_source? this.data_source.initial_page:1;
if(this.data_source && !this.data_source.is_page_loaded_or_loading(first_page))
load_page = first_page;
// If the last thumb in the list is being loaded, we need the next page to continue.
// Note that since get_visible_thumbnails returns thumbs before they actually scroll
// into view, this will happen before the last thumb is actually visible to the user.
var ul = this.container.querySelector(".thumbnails");
if(load_page == null && elements.length > 0 && elements[elements.length-1] == ul.lastElementChild)
{
let last_element = elements[elements.length-1];
load_page = parseInt(last_element.dataset.page)+1;
}
// Hide "no results" if it's shown while we load data.
this.container.querySelector(".no-results").hidden = true;
if(load_page != null)
{
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)
{
console.log("Showing no results");
this.container.querySelector(".no-results").hidden = false;
}
if(!thumbnail_data.singleton().are_all_ids_loaded_or_loading(wanted_illust_ids))
{
// At least one visible thumbnail needs to be loaded, so load more data at the same
// time.
let nearby_illust_ids = this.get_thumbs_to_load();
// Load the thumbnail data if needed.
//
// Loading thumbnail info here very rarely happens anymore, since every data
// source provides thumbnail info with its illust IDs.
thumbnail_data.singleton().get_thumbnail_info(nearby_illust_ids);
}
this.set_visible_thumbs();
}
// Handle clicks on the "load previous results" button.
//
// If we let the regular click handling in main_controller.set_current_data_source do this,
// it'll notice that the requested page isn't loaded and create a new data source. We know
// we can view the previous page, so special case this so we don't lose the pages that are
// already loaded.
//
// This can also trigger for the "return to start" button if we happen to be on page 2.
async thumbnail_onclick(e)
{
// This only matters if the data source supports start pages.
if(!this.data_source.supports_start_page)
return;
let a = e.target.closest("A");
if(a == null)
return;
// 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;
}
update_from_settings()
{
var thumbnail_mode = settings.get("thumbnail-size");
this.set_visible_thumbs();
this.refresh_images();
helpers.set_class(document.body, "light", settings.get("theme") == "light");
helpers.set_class(document.body, "disable-thumbnail-panning", settings.get("disable_thumbnail_panning"));
helpers.set_class(document.body, "disable-thumbnail-zooming", settings.get("disable_thumbnail_zooming"));
helpers.set_class(document.body, "ui-on-hover", settings.get("ui-on-hover"));
helpers.set_class(this.container.querySelector(".recent-history-link"), "disabled", !ppixiv.recently_seen_illusts.get().enabled);
// Flush the top UI transition, so it doesn't animate weirdly when toggling ui-on-hover.
for(let box of document.querySelectorAll(".top-ui-box"))
{
box.classList.add("disable-transition");
box.offsetHeight;
box.classList.remove("disable-transition");
}
}
// Set the URL for all loaded thumbnails that are onscreen.
//
// This won't trigger loading any data (other than the thumbnails themselves).
set_visible_thumbs()
{
// Make a list of IDs that we're assigning.
var elements = this.get_visible_thumbnails();
var illust_ids = [];
for(var element of elements)
{
if(element.dataset.id == null)
continue;
illust_ids.push(element.dataset.id);
}
for(var element of elements)
{
var illust_id = element.dataset.id;
if(illust_id == null)
continue;
var search_mode = this.data_source.search_mode;
let { id: thumb_id, type: thumb_type } = helpers.parse_id(illust_id);
let thumb_data = {};
// For illustrations, get thumbnail info. If we don't have it yet, skip the image (leave it pending)
// and we'll come back once we have it.
if(thumb_type == "illust")
{
// Get thumbnail info.
var info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id);
if(info == null)
continue;
}
// Leave it alone if it's already been loaded.
if(!("pending" in element.dataset))
continue;
// Why is this not working in FF? It works in the console, but not here. Sandboxing
// issue?
// delete element.dataset.pending;
element.removeAttribute("data-pending");
if(thumb_type == "user" || thumb_type == "bookmarks")
{
// This is a user thumbnail rather than an illustration thumbnail. It just shows a small subset
// of info.
let user_id = thumb_id;
var link = element.querySelector("a.thumbnail-link");
if(thumb_type == "user")
link.href = \`/users/\${user_id}/artworks#ppixiv\`;
else
link.href = \`/users/\${user_id}/bookmarks/artworks#ppixiv\`;
link.dataset.userId = user_id;
let quick_user_data = thumbnail_data.singleton().get_quick_user_data(user_id);
if(quick_user_data == null)
{
// We should always have this data for users if the data source asked us to display this user.
throw "Missing quick user data for user ID " + user_id;
}
var thumb = element.querySelector(".thumb");
thumb.src = quick_user_data.profileImageUrl;
var label = element.querySelector(".thumbnail-label");
label.hidden = false;
label.querySelector(".label").innerText = quick_user_data.userName;
// Point the "similar illustrations" thumbnail button to similar users for this result, so you can
// chain from one set of suggested users to another.
element.querySelector("A.similar-illusts-button").href = "/discovery/users#ppixiv?user_id=" + user_id;
continue;
}
if(thumb_type != "illust")
throw "Unexpected thumb type: " + thumb_type;
// Set this thumb.
var url = info.url;
var thumb = element.querySelector(".thumb");
// Check if this illustration is muted (blocked).
var muted_tag = muting.singleton.any_tag_muted(info.tags);
var muted_user = muting.singleton.is_muted_user_id(info.userId);
if(muted_tag || muted_user)
{
element.classList.add("muted");
// The image will be obscured, but we still shouldn't load the image the user blocked (which
// is something Pixiv does wrong). Load the user profile image instead.
thumb.src = info.profileImageUrl;
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;
// The search page thumbs are always square (aspect ratio 1).
helpers.set_thumbnail_panning_direction(element, info.width, info.height, 1);
}
// Set the link. Setting dataset.illustId will allow this to be handled with in-page
// navigation, and the href will allow middle click, etc. to work normally.
//
// If we're on the followed users page, set these to the artist page instead.
var link = element.querySelector("a.thumbnail-link");
if(search_mode == "users") {
link.href = "/users/" + info.userId + "#ppixiv";
}
else
{
link.href = "/artworks/" + illust_id + "#ppixiv";
}
link.dataset.illustId = illust_id;
link.dataset.userId = info.userId;
// Don't show this UI when we're in the followed users view.
if(search_mode == "illusts")
{
if(info.illustType == 2)
element.querySelector(".ugoira-icon").hidden = false;
if(info.pageCount > 1)
{
var pageCountBox = element.querySelector(".page-count-box");
pageCountBox.hidden = false;
pageCountBox.href = link.href + "?view=manga";
element.querySelector(".page-count-box .page-count").textContent = info.pageCount;
}
}
helpers.set_class(element, "dot", helpers.tags_contain_dot(info));
// On most pages, the suggestions button in thumbnails shows similar illustrations. On following,
// show similar artists instead.
if(search_mode == "users")
element.querySelector("A.similar-illusts-button").href = "/discovery/users#ppixiv?user_id=" + info.userId;
else
element.querySelector("A.similar-illusts-button").href = "/bookmark_detail.php?illust_id=" + illust_id + "#ppixiv?recommendations=1";
this.refresh_bookmark_icon(element);
// Set the label. This is only actually shown in following views.
var label = element.querySelector(".thumbnail-label");
if(search_mode == "users") {
label.hidden = false;
label.querySelector(".label").innerText = info.userName;
} else {
label.hidden = true;
}
}
if(this.data_source != null)
{
// Set the link for the first page and previous page buttons. Most of the time this is handled
// by our in-page click handler.
let page = this.data_source.get_start_page(helpers.args.location);
let previous_page_link = this.container.querySelector("a.load-previous-page-link");
if(previous_page_link)
{
let args = helpers.args.location;
this.data_source.set_start_page(args, page-1);
previous_page_link.href = args.url;
}
let first_page_link = this.container.querySelector("a.load-first-page-link");
if(first_page_link)
{
let args = helpers.args.location;
this.data_source.set_start_page(args, 1);
first_page_link.href = args.url;
}
}
}
// Refresh the thumbnail for illust_id.
//
// This is used to refresh the bookmark icon when changing a bookmark.
refresh_thumbnail(illust_id)
{
var ul = this.container.querySelector(".thumbnails");
var thumbnail_element = ul.querySelector("[data-id=\\"" + illust_id + "\\"]");
if(thumbnail_element == null)
return;
this.refresh_bookmark_icon(thumbnail_element);
}
// Set the bookmarked heart for thumbnail_element. This can change if the user bookmarks
// or un-bookmarks an image.
refresh_bookmark_icon(thumbnail_element)
{
if(this.data_source && this.data_source.search_mode == "users")
return;
var illust_id = thumbnail_element.dataset.id;
if(illust_id == null)
return;
// Get thumbnail info.
var thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id);
if(thumbnail_info == null)
return;
var show_bookmark_heart = thumbnail_info.bookmarkData != null;
if(this.data_source != null && !this.data_source.show_bookmark_icons)
show_bookmark_heart = false;
thumbnail_element.querySelector(".heart.public").hidden = !show_bookmark_heart || thumbnail_info.bookmarkData.private;
thumbnail_element.querySelector(".heart.private").hidden = !show_bookmark_heart || !thumbnail_info.bookmarkData.private;
}
// Return a list of thumbnails that are either visible, or close to being visible
// (so we load thumbs before they actually come on screen).
get_visible_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 a given number of thumb that should be loaded, starting with thumbs that are onscreen
// and working outwards until we have enough.
get_thumbs_to_load(count=100)
{
// 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 [];
let results = [];
let add_element = (element) =>
{
if(element == null)
return;
if(element.dataset.id == null)
return;
let { type, id } = helpers.parse_id(element.dataset.id);
if(type != "illust")
return;
// Skip this thumb if it's already loading.
if(thumbnail_data.singleton().is_id_loaded_or_loading(id))
return;
results.push(id);
}
let onscreen_thumbs = this.container.querySelectorAll(\`.thumbnails > [data-id][data-fully-on-screen]\`);
if(onscreen_thumbs.length == 0)
return [];
// First, add all thumbs that are onscreen, so these are prioritized.
for(let thumb of onscreen_thumbs)
add_element(thumb);
// Walk forwards and backwards around the initial results.
let forwards = onscreen_thumbs[onscreen_thumbs.length-1];
let backwards = onscreen_thumbs[0];
while(forwards || backwards)
{
if(results.length >= count)
break;
if(forwards)
forwards = forwards.nextElementSibling;
if(backwards)
backwards = backwards.previousElementSibling;
add_element(forwards);
add_element(backwards);
}
return results;
}
// Create a thumb placeholder. This doesn't load the image yet.
//
// illust_id is the illustration this will be if it's displayed, or null if this
// is a placeholder for pages we haven't loaded. page is the page this illustration
// is on (whether it's a placeholder or not).
create_thumb(illust_id, page)
{
let template_type = ".template-thumbnail";
if(illust_id == "special:previous-page")
template_type = ".template-load-previous-results";
// Cache a reference to the thumbnail template. We can do this a lot, and this
// query takes a lot of page setup time if we run it for each thumb.
if(this.thumbnail_templates[template_type] == null)
this.thumbnail_templates[template_type] = helpers.get_template(template_type);
let entry = helpers.create_from_template(this.thumbnail_templates[template_type]);
// If this is a non-thumb entry, mark it so we ignore it for "nearby thumb" handling, etc.
if(illust_id == "special:previous-page")
entry.dataset.special = 1;
// Mark that this thumb hasn't been filled in yet.
entry.dataset.pending = true;
if(illust_id != null)
entry.dataset.id = illust_id;
entry.dataset.page = page;
for(let observer of this.intersection_observers)
observer.observe(entry);
return entry;
}
// This is called when thumbnail_data has loaded more thumbnail info.
thumbs_loaded(e)
{
this.set_visible_thumbs();
}
// Scroll to illust_id if it's available. This is called when we display the thumbnail view
// after coming from an illustration.
scroll_to_illust_id(illust_id)
{
var thumb = this.container.querySelector("[data-id='" + illust_id + "']");
if(thumb == null)
return;
// If the item isn't visible, center it.
var scroll_pos = this.container.scrollTop;
if(thumb.offsetTop < scroll_pos || thumb.offsetTop + thumb.offsetHeight > scroll_pos + this.container.offsetHeight)
this.container.scrollTop = thumb.offsetTop + thumb.offsetHeight/2 - this.container.offsetHeight/2;
};
pulse_thumbnail(illust_id)
{
var thumb = this.container.querySelector("[data-id='" + illust_id + "']");
if(thumb == null)
return;
this.stop_pulsing_thumbnail();
this.flashing_image = thumb;
thumb.classList.add("flash");
};
// Work around a bug in CSS animations: even if animation-iteration-count is 1,
// the animation will play again if the element is hidden and displayed again, which
// causes previously-flashed thumbnails to flash every time we exit and reenter
// thumbnails.
stop_pulsing_thumbnail()
{
if(this.flashing_image == null)
return;
this.flashing_image.classList.remove("flash");
this.flashing_image = null;
};
// Handle submitting searches on the user search page.
submit_user_search(e)
{
let search = this.container.querySelector(".user-search-box input.search-users").value;
let url = new URL("/search_user.php#ppixiv", ppixiv.location);
url.searchParams.append("nick", search);
url.searchParams.append("s_mode", "s_usr");
helpers.set_page_url(url, true);
}
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/screen_search.js
`;
ppixiv.resources["src/screen_manga.js"] = `"use strict";
// A full page viewer for manga thumbnails.
//
// This is similar to the main search view. It doesn't share code, since it
// works differently enough that it would complicate things too much.
ppixiv.screen_manga = class extends ppixiv.screen
{
constructor(container)
{
super(container);
this.refresh_ui = this.refresh_ui.bind(this);
this.window_onresize = this.window_onresize.bind(this);
this.refresh_count = 0;
window.addEventListener("resize", this.window_onresize);
// If the "view muted image" button is clicked, add view-muted to the URL.
this.container.querySelector(".view-muted-image").addEventListener("click", (e) => {
let args = helpers.args.location;
args.hash.set("view-muted", "1");
helpers.set_page_url(args, false /* add_to_history */, "override-mute");
});
this.progress_bar = main_controller.singleton.progress_bar;
this.ui = new image_ui(this.container.querySelector(".ui-container"), this.progress_bar);
this.scroll_positions_by_illust_id = {};
image_data.singleton().user_modified_callbacks.register(this.refresh_ui);
image_data.singleton().illust_modified_callbacks.register(this.refresh_ui);
settings.register_change_callback("manga-thumbnail-size", this.refresh_ui);
// Create a style for our thumbnail style.
this.thumbnail_dimensions_style = helpers.create_style("");
document.body.appendChild(this.thumbnail_dimensions_style);
this.set_active(false, { });
}
window_onresize(e)
{
if(!this._active)
return;
this.refresh_ui();
}
async set_active(active, { illust_id })
{
if(this.illust_id != illust_id)
{
// The load itself is async and might not happen immediately if we don't have page info yet.
// Clear any previous image list so it doesn't flash on screen while we load the new info.
let ul = this.container.querySelector(".thumbnails");
helpers.remove_elements(ul);
this.illust_id = illust_id;
this.illust_info = null;
this.ui.illust_id = illust_id;
// Refresh even if illust_id is null, so we quickly clear the screen.
await this.refresh_ui();
}
if(this._active && !active)
{
// Save the old scroll position.
if(this.illust_id != null)
{
console.log("save scroll position for", this.illust_id, this.container.scrollTop);
this.scroll_positions_by_illust_id[this.illust_id] = this.container.scrollTop;
}
// Hide the dropdown tag widget.
this.ui.bookmark_tag_widget.visible = false;
// Stop showing the user in the context menu.
main_context_menu.get.user_id = null;
}
this._active = active;
// This will hide or unhide us.
await super.set_active(active);
if(!active || this.illust_id == null)
return;
// The rest of the load happens async. Although we're already in an async
// function, it should return without waiting for API requests.
this.async_set_image();
}
async async_set_image()
{
console.log("Loading manga screen for:", this.illust_id);
// Load image info.
var illust_info = await image_data.singleton().get_image_info(this.illust_id);
if(illust_info.id != this.illust_id)
return;
this.illust_info = illust_info;
await this.refresh_ui();
}
get view_muted()
{
return helpers.args.location.hash.get("view-muted") == "1";
}
should_hide_muted_image()
{
let muted_tag = muting.singleton.any_tag_muted(image_data.from_tag_list(this.illust_info.tags));
let muted_user = muting.singleton.is_muted_user_id(this.illust_info.userId);
if(this.view_muted || (!muted_tag && !muted_user))
return { is_muted: false };
return { is_muted: true, muted_tag: muted_tag, muted_user: muted_user };
}
update_mute()
{
// Check if this post is muted.
let { is_muted, muted_tag, muted_user } = this.should_hide_muted_image();
this.hiding_muted_image = this.view_muted;
this.container.querySelector(".muted-text").hidden = !is_muted;
if(!is_muted)
return false;
let muted_label = this.container.querySelector(".muted-label");
if(muted_tag)
tag_translations.get().set_translated_tag(muted_label, muted_tag);
else if(muted_user)
muted_label.innerText = this.illust_info.userName;
return true;
}
refresh_ui = async () =>
{
if(!this._active)
return;
helpers.set_title_and_icon(this.illust_info);
var original_scroll_top = this.container.scrollTop;
var ul = this.container.querySelector(".thumbnails");
helpers.remove_elements(ul);
if(this.illust_info == null)
return;
// Tell the context menu which user is being viewed.
main_context_menu.get.user_id = this.illust_info.userId;
if(this.update_mute())
return;
// Get the aspect ratio to crop images to.
var ratio = this.get_display_aspect_ratio(this.illust_info.mangaPages);
let thumbnail_size = settings.get("manga-thumbnail-size", 4);
thumbnail_size = thumbnail_size_slider_widget.thumbnail_size_for_value(thumbnail_size);
this.thumbnail_dimensions_style.textContent = helpers.make_thumbnail_sizing_style(ul, ".screen-manga-container", {
wide: true,
size: thumbnail_size,
ratio: ratio,
// We preload this page anyway since it doesn't cause a lot of API calls, so we
// can allow a high column count and just let the size take over.
max_columns: 15,
});
for(var page = 0; page < this.illust_info.mangaPages.length; ++page)
{
var manga_page = this.illust_info.mangaPages[page];
var entry = this.create_thumb(page, manga_page);
var link = entry.querySelector(".thumbnail-link");
helpers.set_thumbnail_panning_direction(entry, manga_page.width, manga_page.height, ratio);
ul.appendChild(entry);
}
// Restore the value of scrollTop from before we updated. For some reason, Firefox
// modifies scrollTop after we add a bunch of items, which causes us to scroll to
// the wrong position, even though scrollRestoration is disabled.
this.container.scrollTop = original_scroll_top;
}
get active()
{
return this._active;
}
get displayed_illust_id()
{
return this.illust_id;
}
// Navigating out goes back to the search.
get navigate_out_target() { return "search"; }
// Given a list of manga infos, return the aspect ratio we'll crop them to.
get_display_aspect_ratio(manga_info)
{
// A lot of manga posts use the same resolution for all images, or just have
// one or two exceptions for things like title pages. If most images have
// about the same aspect ratio, use it.
var total = 0;
for(var manga_page of manga_info)
total += manga_page.width / manga_page.height;
var average_aspect_ratio = total / manga_info.length;
var illusts_far_from_average = 0;
for(var manga_page of manga_info)
{
var ratio = manga_page.width / manga_page.height;
if(Math.abs(average_aspect_ratio - ratio) > 0.1)
illusts_far_from_average++;
}
// If we didn't find a common aspect ratio, just use square thumbs.
if(illusts_far_from_average > 3)
return 1;
else
return average_aspect_ratio;
}
get_display_resolution(width, height)
{
var fit_width = 300;
var fit_height = 300;
var ratio = width / fit_width;
if(ratio > 1)
{
height /= ratio;
width /= ratio;
}
var ratio = height / fit_height;
if(ratio > 1)
{
height /= ratio;
width /= ratio;
}
return [width, height];
}
create_thumb(page_idx, manga_page)
{
if(this.thumbnail_template == null)
this.thumbnail_template = document.body.querySelector(".template-manga-view-thumbnail");
var element = helpers.create_from_template(this.thumbnail_template);
// These URLs should be the 540x540_70 master version, which is a non-squared high-res
// thumbnail. These tend to be around 30-40k, so loading a full manga set of them is
// quick.
//
// XXX: switch this to 540x540_10_webp in Chrome, around 5k?
var thumb = element.querySelector(".thumb");
var url = manga_page.urls.small;
// url = url.replace("/540x540_70/", "/540x540_10_webp/");
thumb.src = url;
var size = this.get_display_resolution(manga_page.width, manga_page.height);
thumb.width = size[0];
thumb.height = size[1];
var link = element.querySelector("a.thumbnail-link");
link.href = "/artworks/" + this.illust_id + "#ppixiv?page=" + (page_idx+1);
link.dataset.illustId = this.illust_id;
link.dataset.pageIdx = page_idx;
// We don't use intersection checking for the manga view right now. Mark entries
// with all of the "image onscreen" tags.
element.dataset.nearby = true;
element.dataset.fartherAway = true;
element.dataset.fullyOnScreen = true;
element.dataset.pageIdx = page_idx;
return element;
}
scroll_to_top()
{
// Read offsetHeight to force layout to happen. If we don't do this, setting scrollTop
// sometimes has no effect in Firefox.
this.container.offsetHeight;
this.container.scrollTop = 0;
console.log("scroll to top", this.container.scrollTop, this.container.hidden, this.container.offsetHeight);
}
restore_scroll_position()
{
// If we saved a scroll position when navigating away from a data source earlier,
// restore it now. Only do this once.
var scroll_pos = this.scroll_positions_by_illust_id[this.illust_id];
if(scroll_pos != null)
{
console.log("scroll pos:", scroll_pos);
this.container.scrollTop = scroll_pos;
delete this.scroll_positions_by_illust_id[this.illust_id];
}
else
this.scroll_to_top();
}
scroll_to_illust_id(illust_id, manga_page)
{
if(manga_page == null)
return;
var thumb = this.container.querySelector('[data-page-idx="' + manga_page + '"]');
if(thumb == null)
return;
console.log("Scrolling to", thumb);
// If the item isn't visible, center it.
var scroll_pos = this.container.scrollTop;
if(thumb.offsetTop < scroll_pos || thumb.offsetTop + thumb.offsetHeight > scroll_pos + this.container.offsetHeight)
this.container.scrollTop = thumb.offsetTop + thumb.offsetHeight/2 - this.container.offsetHeight/2;
}
handle_onkeydown(e)
{
this.ui.handle_onkeydown(e);
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/screen_manga.js
`;
ppixiv.resources["src/image_ui.js"] = `"use strict";
// This handles the overlay UI on the illustration page.
ppixiv.image_ui = class
{
constructor(container, progress_bar)
{
this.clicked_download = this.clicked_download.bind(this);
this.refresh = this.refresh.bind(this);
this.container = container;
this.progress_bar = progress_bar;
this.ui = helpers.create_from_template(".template-image-ui");
this.container.appendChild(this.ui);
this.avatar_widget = new avatar_widget({
parent: this.container.querySelector(".avatar-popup"),
mode: "dropdown",
});
this.tag_widget = new tag_widget({
parent: this.container.querySelector(".tag-list"),
});
// Set up hover popups.
dropdown_menu_opener.create_handlers(this.container, [".image-settings-menu-box"]);
image_data.singleton().illust_modified_callbacks.register(this.refresh);
this.bookmark_tag_widget = new bookmark_tag_list_widget(this.container.querySelector(".popup-bookmark-tag-dropdown-container"));
this.toggle_tag_widget = new toggle_bookmark_tag_list_widget(this.container.querySelector(".button-bookmark-tags"), this.bookmark_tag_widget);
this.like_button = new like_button_widget(this.container.querySelector(".button-like"));
// The bookmark buttons, and clicks in the tag dropdown:
this.bookmark_buttons = [];
for(var a of this.container.querySelectorAll(".button-bookmark"))
this.bookmark_buttons.push(new bookmark_button_widget(a, a.classList.contains("private"), this.bookmark_tag_widget));
for(let button of this.container.querySelectorAll(".download-button"))
button.addEventListener("click", this.clicked_download);
this.container.querySelector(".download-manga-button").addEventListener("click", this.clicked_download);
this.container.querySelector(".navigate-out-button").addEventListener("click", function(e) {
main_controller.singleton.navigate_out();
}.bind(this));
var settings_menu = this.container.querySelector(".settings-menu-box > .popup-menu-box");
menu_option.add_settings(settings_menu);
}
set visible(value)
{
this.avatar_widget.visible = value;
}
set data_source(data_source)
{
if(this._data_source == data_source)
return;
this._data_source = data_source;
this.refresh();
}
shutdown()
{
image_data.singleton().illust_modified_callbacks.unregister(this.refresh);
this.avatar_widget.shutdown();
}
get illust_id()
{
return this._illust_id;
}
set illust_id(illust_id)
{
if(this._illust_id == illust_id)
return;
this._illust_id = illust_id;
this.illust_data = null;
this.like_button.illust_id = illust_id;
this.bookmark_tag_widget.illust_id = illust_id;
this.toggle_tag_widget.illust_id = illust_id;
for(let button of this.bookmark_buttons)
button.illust_id = illust_id;
if(illust_id == null)
{
this.refresh();
return;
}
image_data.singleton().get_image_info(illust_id).then((illust_info) => {
if(illust_info.illustId != this._illust_id)
return;
this.illust_data = illust_info;
this.refresh();
});
}
handle_onkeydown(e)
{
}
refresh()
{
if(this.illust_data == null)
return;
var illust_data = this.illust_data;
var illust_id = illust_data.illustId;
let user_id = illust_data.userId;
// Show the author if it's someone else's post, or the edit link if it's ours.
var our_post = global_data.user_id == user_id;
this.container.querySelector(".author-block").hidden = our_post;
this.container.querySelector(".edit-post").hidden = !our_post;
this.container.querySelector(".edit-post").href = "/member_illust_mod.php?mode=mod&illust_id=" + illust_id;
this.avatar_widget.set_user_id(user_id);
this.tag_widget.set(illust_data.tags);
var element_title = this.container.querySelector(".title");
element_title.textContent = illust_data.illustTitle;
element_title.href = "/artworks/" + illust_id + "#ppixiv";
var element_author = this.container.querySelector(".author");
element_author.textContent = illust_data.userName;
element_author.href = \`/users/\${user_id}#ppixiv\`;
this.container.querySelector(".similar-illusts-button").href = "/bookmark_detail.php?illust_id=" + illust_id + "#ppixiv?recommendations=1";
this.container.querySelector(".similar-artists-button").href = "/discovery/users#ppixiv?user_id=" + user_id;
this.container.querySelector(".similar-bookmarks-button").href = "/bookmark_detail.php?illust_id=" + illust_id + "#ppixiv";
// Fill in the post info text.
this.set_post_info(this.container.querySelector(".post-info"));
// The comment (description) can contain HTML.
var element_comment = this.container.querySelector(".description");
element_comment.hidden = illust_data.illustComment == "";
element_comment.innerHTML = illust_data.illustComment;
helpers.fix_pixiv_links(element_comment);
helpers.make_pixiv_links_internal(element_comment);
// Set the download button popup text.
if(this.illust_data != null)
{
let download_image_button = this.container.querySelector(".download-image-button");
download_image_button.hidden = !actions.is_download_type_available("image", this.illust_data);
let download_manga_button = this.container.querySelector(".download-manga-button");
download_manga_button.hidden = !actions.is_download_type_available("ZIP", this.illust_data);
let download_video_button = this.container.querySelector(".download-video-button");
download_video_button.hidden = !actions.is_download_type_available("MKV", this.illust_data);
}
// Set the popup for the thumbnails button.
var navigate_out_label = main_controller.singleton.navigate_out_label;
var title = navigate_out_label != null? ("Return to " + navigate_out_label):"";
this.container.querySelector(".navigate-out-button").dataset.popup = title;
}
set_post_info(post_info_container)
{
var illust_data = this.illust_data;
var set_info = (query, text) =>
{
var node = post_info_container.querySelector(query);
node.innerText = text;
node.hidden = text == "";
};
var seconds_old = (new Date() - new Date(illust_data.createDate)) / 1000;
set_info(".post-age", helpers.age_to_string(seconds_old) + " ago");
post_info_container.querySelector(".post-age").dataset.popup = helpers.date_to_string(illust_data.createDate);
var info = "";
// Add the resolution and file type if available.
if(this.displayed_page != null && this.illust_data != null)
{
var page_info = this.illust_data.mangaPages[this.displayed_page];
info += page_info.width + "x" + page_info.height;
}
var ext = this.viewer? this.viewer.current_image_type:null;
if(ext != null)
info += " " + ext;
set_info(".image-info", info);
var duration = "";
if(illust_data.illustType == 2)
{
var seconds = 0;
for(var frame of illust_data.ugoiraMetadata.frames)
seconds += frame.delay / 1000;
var duration = seconds.toFixed(duration >= 10? 0:1);
duration += seconds == 1? " second":" seconds";
}
set_info(".ugoira-duration", duration);
set_info(".ugoira-frames", illust_data.illustType == 2? (illust_data.ugoiraMetadata.frames.length + " frames"):"");
// Add the page count for manga.
var page_text = "";
if(illust_data.pageCount > 1 && this.displayed_page != null)
page_text = "Page " + (this.displayed_page+1) + "/" + illust_data.pageCount;
set_info(".page-count", page_text);
}
// Set the resolution to display in image info. If both are null, no resolution
// is displayed.
set_displayed_page_info(page)
{
console.assert(page == null || page >= 0);
this.displayed_page = page;
this.refresh();
}
clicked_download(e)
{
if(this.illust_data == null)
return;
var clicked_button = e.target.closest(".download-button");
if(clicked_button == null)
return;
e.preventDefault();
e.stopPropagation();
let download_type = clicked_button.dataset.download;
actions.download_illust(this.illust_id, this.progress_bar.controller(), download_type, this.displayed_page);
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/image_ui.js
`;
ppixiv.resources["src/tag_search_dropdown_widget.js"] = `"use strict";
// Handle showing the search history and tag edit dropdowns.
ppixiv.tag_search_box_widget = class
{
constructor(container)
{
this.input_onfocus = this.input_onfocus.bind(this);
this.input_onblur = this.input_onblur.bind(this);
this.container_onmouseenter = this.container_onmouseenter.bind(this);
this.container_onmouseleave = this.container_onmouseleave.bind(this);
this.submit_search = this.submit_search.bind(this);
this.container = container;
this.input_element = this.container.querySelector(".search-tags");
this.dropdown_widget = new tag_search_dropdown_widget(container);
this.edit_widget = new tag_search_edit_widget(container);
this.container.addEventListener("mouseenter", this.container_onmouseenter);
this.container.addEventListener("mouseleave", this.container_onmouseleave);
this.input_element.addEventListener("focus", this.input_onfocus);
this.input_element.addEventListener("blur", this.input_onblur);
let edit_button = this.container.querySelector(".edit-search-button");
if(edit_button)
{
edit_button.addEventListener("click", (e) => {
// Toggle the edit widget, hiding the search history dropdown if it's shown.
if(this.dropdown_widget.shown)
this.hide();
if(this.edit_widget.shown)
this.hide();
else
this.show_edit();
});
}
// Search submission:
helpers.input_handler(this.input_element, this.submit_search);
this.container.querySelector(".search-submit-button").addEventListener("click", this.submit_search);
// Hide the dropdowns on navigation.
new view_hidden_listener(this.input_element, (e) => {
this.hide();
});
}
async show_history()
{
// Don't show history if search editing is already open.
if(this.edit_widget.shown)
return;
this.dropdown_widget.show();
}
show_edit()
{
// Don't show search editing if history is already open.
if(this.dropdown_widget.shown)
return;
this.edit_widget.show();
// Disable adding searches to search history while the edit dropdown is open. Otherwise,
// every time a tag is toggled, that combination of tags is added to search history by
// data_source_search, which makes a mess.
helpers.disable_adding_search_tags(true);
}
hide()
{
helpers.disable_adding_search_tags(false);
this.dropdown_widget.hide();
this.edit_widget.hide();
}
container_onmouseenter(e)
{
this.mouse_over_parent = true;
}
container_onmouseleave(e)
{
this.mouse_over_parent = false;
if(this.dropdown_widget.shown && !this.input_focused && !this.mouse_over_parent)
this.hide();
}
// Show the dropdown when the input is focused. Hide it when the input is both
// unfocused and this.container isn't being hovered. This way, the input focus
// can leave the input box to manipulate the dropdown without it being hidden,
// but we don't rely on hovering to keep the dropdown open.
input_onfocus(e)
{
this.input_focused = true;
if(!this.dropdown_widget.shown && !this.edit_widget.shown)
this.show_history();
}
input_onblur(e)
{
this.input_focused = false;
if(this.dropdown_widget.shown && !this.input_focused && !this.mouse_over_parent)
this.hide();
}
submit_search(e)
{
// This can be sent to either the search page search box or the one in the
// navigation dropdown. Figure out which one we're on.
var search_box = e.target.closest(".search-box");
var tags = this.input_element.value.trim();
if(tags.length == 0)
return;
// Add this tag to the recent search list.
helpers.add_recent_search_tag(tags);
// If we're submitting by pressing enter on an input element, unfocus it and
// close any widgets inside it (tag dropdowns).
if(e.target instanceof HTMLInputElement)
{
e.target.blur();
view_hidden_listener.send_viewhidden(e.target);
}
// Run the search.
helpers.set_page_url(page_manager.singleton().get_url_for_tag_search(tags, ppixiv.location), true);
}
}
ppixiv.tag_search_dropdown_widget = class
{
constructor(container)
{
this.dropdown_onclick = this.dropdown_onclick.bind(this);
this.input_onkeydown = this.input_onkeydown.bind(this);
this.input_oninput = this.input_oninput.bind(this);
this.populate_dropdown = this.populate_dropdown.bind(this);
this.container = container;
this.input_element = this.container.querySelector(".search-tags");
this.input_element.addEventListener("keydown", this.input_onkeydown);
this.input_element.addEventListener("input", this.input_oninput);
// Refresh the dropdown when the tag search history changes.
window.addEventListener("recent-tag-searches-changed", this.populate_dropdown);
// Add the dropdown widget.
this.tag_dropdown = helpers.create_from_template(".template-tag-dropdown");
this.tag_dropdown.addEventListener("click", this.dropdown_onclick);
this.container.appendChild(this.tag_dropdown);
this.current_autocomplete_results = [];
// input-dropdown is resizable. Save the size when the user drags it.
this.input_dropdown = this.tag_dropdown.querySelector(".input-dropdown");
let observer = new MutationObserver((mutations) => {
// resize sets the width. Use this instead of offsetWidth, since offsetWidth sometimes reads
// as 0 here.
settings.set("tag-dropdown-width", this.input_dropdown.style.width);
});
observer.observe(this.input_dropdown, { attributes: true });
// Restore input-dropdown's width. Force a minimum width, in case this setting is saved incorrectly.
this.input_dropdown.style.width = settings.get("tag-dropdown-width", "400px");
this.shown = false;
this.tag_dropdown.hidden = true;
// Sometimes the popup closes when searches are clicked and sometimes they're not. Make sure
// we always close on navigation.
this.tag_dropdown.addEventListener("click", (e) => {
if(e.defaultPrevented)
return;
let a = e.target.closest("A");
if(a == null)
return;
this.input_element.blur();
this.hide();
});
}
dropdown_onclick(e)
{
var remove_entry = e.target.closest(".remove-history-entry");
if(remove_entry != null)
{
// Clicked X to remove a tag from history.
e.stopPropagation();
e.preventDefault();
var tag = e.target.closest(".entry").dataset.tag;
helpers.remove_recent_search_tag(tag);
return;
}
// Close the dropdown if the user clicks a tag (but not when clicking
// remove-history-entry).
if(e.target.closest(".tag"))
this.hide();
}
input_onkeydown(e)
{
// Only handle inputs when we're open.
if(this.tag_dropdown.hidden)
return;
switch(e.keyCode)
{
case 38: // up arrow
case 40: // down arrow
e.preventDefault();
e.stopImmediatePropagation();
this.move(e.keyCode == 40);
break;
}
}
input_oninput(e)
{
if(this.tag_dropdown.hidden)
return;
// Clear the selection on input.
this.set_selection(null);
// Update autocomplete when the text changes.
this.run_autocomplete();
}
async show()
{
if(this.shown)
return;
this.shown = true;
// Fill in the dropdown before displaying it. If hide() is called before this
// finishes this will return false, so stop.
if(!await this.populate_dropdown())
return;
this.tag_dropdown.hidden = false;
}
hide()
{
if(!this.shown)
return;
this.shown = false;
// If populate_dropdown is still running, cancel it.
this.cancel_populate_dropdown();
this.tag_dropdown.hidden = true;
// Make sure the input isn't focused.
this.input_element.blur();
}
async run_autocomplete()
{
// If true, this is a value change caused by keyboard navigation. Don't run autocomplete,
// since we don't want to change the dropdown due to navigating in it.
if(this.navigating)
return;
var tags = this.input_element.value.trim();
// Stop if we're already up to date.
if(this.most_recent_search == tags)
return;
if(this.autocomplete_request != null)
{
// If an autocomplete request is already running, let it finish before we
// start another. This matches the behavior of Pixiv's input forms.
console.log("Delaying search for", tags);
return;
}
if(tags == "")
{
// Don't send requests with an empty string. Just finish the search synchronously,
// so we clear the autocomplete immediately.
if(this.abort_autocomplete != null)
this.abort_autocomplete.abort();
this.autocomplete_request_finished("", { candidates: [] });
return;
}
// Run the search.
try {
this.abort_autocomplete = new AbortController();
var result = await helpers.rpc_get_request("/rpc/cps.php", {
keyword: tags,
}, {
signal: this.abort_autocomplete.signal,
});
this.autocomplete_request_finished(tags, result);
} catch(e) {
console.info("Tag autocomplete aborted:", e);
} finally {
this.abort_autocomplete = null;
}
}
// A tag autocomplete request finished.
autocomplete_request_finished(tags, result)
{
this.most_recent_search = tags;
this.abort_autocomplete = null;
// Store the new results.
this.current_autocomplete_results = result.candidates || [];
// Refresh the dropdown with the new results.
this.populate_dropdown();
// If the input element's value has changed since we started this search, we
// stalled any other autocompletion. Start it now.
if(tags != this.input_element.value)
{
console.log("Run delayed autocomplete");
this.run_autocomplete();
}
}
// tag_search is a search, like "tag -tag2". translated_tags is a dictionary of known translations.
create_entry(tag_search, translated_tags)
{
var entry = helpers.create_from_template(".template-tag-dropdown-entry");
entry.dataset.tag = tag_search;
let translated_tag = translated_tags[tag_search];
if(translated_tag)
entry.dataset.translated_tag = translated_tag;
let tag_container = entry.querySelector(".search");
for(let tag of helpers.split_search_tags(tag_search))
{
if(tag == "")
continue;
// Force "or" lowercase.
if(tag.toLowerCase() == "or")
tag = "or";
let span = document.createElement("span");
span.dataset.tag = tag;
span.classList.add("word");
if(tag == "or")
span.classList.add("or");
else
span.classList.add("tag");
// Split off - prefixes to look up the translation, then add it back.
let prefix_and_tag = helpers.split_tag_prefixes(tag);
let translated_tag = translated_tags[prefix_and_tag[1]];
if(translated_tag)
translated_tag = prefix_and_tag[0] + translated_tag;
span.innerText = translated_tag || tag;
if(translated_tag)
span.dataset.translated_tag = translated_tag;
tag_container.appendChild(span);
}
var url = page_manager.singleton().get_url_for_tag_search(tag_search, 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.tag_dropdown.querySelectorAll(".input-dropdown-list .entry");
if(this.selected_idx != null)
all_entries[this.selected_idx].classList.remove("selected");
// Set the new selection.
this.selected_idx = idx;
if(this.selected_idx != null)
{
var new_entry = all_entries[this.selected_idx];
new_entry.classList.add("selected");
this.input_element.value = new_entry.dataset.tag;
}
} finally {
this.navigating = false;
}
}
// Select the next or previous entry in the dropdown.
move(down)
{
var all_entries = this.tag_dropdown.querySelectorAll(".input-dropdown-list .entry");
// Stop if there's nothing in the list.
var total_entries = all_entries.length;
if(total_entries == 0)
return;
var idx = this.selected_idx;
if(idx == null)
idx = down? 0:(total_entries-1);
else
idx += down? +1:-1;
idx %= total_entries;
this.set_selection(idx);
}
// Populate the tag dropdown.
//
// This is async, since IndexedDB is async. (It shouldn't be. It's an overcorrection.
// Network APIs should be async, but local I/O should not be forced async.) If another
// call to populate_dropdown() is made before this completes or cancel_populate_dropdown
// cancels it, return false. If it completes, return true.
async populate_dropdown()
{
// If another populate_dropdown is already running, cancel it and restart.
this.cancel_populate_dropdown();
// Set populate_dropdown_abort to an AbortController for this call.
let abort_controller = this.populate_dropdown_abort = new AbortController();
let abort_signal = abort_controller.signal;
var tag_searches = settings.get("recent-tag-searches") || [];
// Separate tags in each search, so we can look up translations.
//
var all_tags = {};
for(let tag_search of tag_searches)
{
for(let tag of helpers.split_search_tags(tag_search))
{
tag = helpers.split_tag_prefixes(tag)[1];
all_tags[tag] = true;
}
}
all_tags = Object.keys(all_tags);
let translated_tags = await tag_translations.get().get_translations(all_tags, "en");
// Check if we were aborted while we were loading tags.
if(abort_signal && abort_signal.aborted)
{
console.log("populate_dropdown_inner aborted");
return false;
}
var list = this.tag_dropdown.querySelector(".input-dropdown-list");
helpers.remove_elements(list);
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
{
constructor(container)
{
this.dropdown_onclick = this.dropdown_onclick.bind(this);
this.populate_dropdown = this.populate_dropdown.bind(this);
this.container = container;
this.input_element = this.container.querySelector(".search-tags");
// Refresh the dropdown when the tag search history changes.
window.addEventListener("recent-tag-searches-changed", this.populate_dropdown);
// Add the dropdown widget.
this.tag_dropdown = helpers.create_from_template(".template-edit-search-dropdown");
this.tag_dropdown.addEventListener("click", this.dropdown_onclick);
this.container.appendChild(this.tag_dropdown);
// Refresh tags if the user edits the search directly.
this.input_element.addEventListener("input", (e) => { this.refresh_highlighted_tags(); });
// input-dropdown is resizable. Save the size when the user drags it.
this.input_dropdown = this.tag_dropdown.querySelector(".input-dropdown");
let observer = new MutationObserver((mutations) => {
// resize sets the width. Use this instead of offsetWidth, since offsetWidth sometimes reads
// as 0 here.
settings.set("search-edit-dropdown-width", this.input_dropdown.style.width);
});
observer.observe(this.input_dropdown, { attributes: true });
// Restore input-dropdown's width. Force a minimum width, in case this setting is saved incorrectly.
this.input_dropdown.style.width = settings.get("search-edit-dropdown-width", "400px");
this.shown = false;
this.tag_dropdown.hidden = true;
}
dropdown_onclick(e)
{
e.preventDefault();
e.stopImmediatePropagation();
// Clicking tags toggles the tag in the search box.
let tag = e.target.closest(".tag");
if(tag == null)
return;
this.toggle_tag(tag.dataset.tag);
// Control-clicking the tag probably caused its enclosing search link to be focused, which will
// cause it to activate when enter is pressed. Switch focus to the input box, so pressing enter
// will submit the search.
this.input_element.focus();
}
async show()
{
if(this.shown)
return;
this.shown = true;
// Fill in the dropdown before displaying it. If hide() is called before this
// finishes this will return false, so stop.
if(!await this.populate_dropdown())
return;
this.tag_dropdown.hidden = false;
}
hide()
{
if(!this.shown)
return;
this.shown = false;
// If populate_dropdown is still running, cancel it.
this.cancel_populate_dropdown();
this.tag_dropdown.hidden = true;
// Make sure the input isn't focused.
this.input_element.blur();
}
// tag_search is a search, like "tag -tag2". translated_tags is a dictionary of known translations.
create_entry(tag_search, translated_tags)
{
var entry = helpers.create_from_template(".template-edit-search-dropdown-entry");
entry.dataset.tag = tag_search;
let translated_tag = translated_tags[tag_search];
if(translated_tag)
entry.dataset.translated_tag = translated_tag;
let tag_container = entry.querySelector(".search");
for(let tag of helpers.split_search_tags(tag_search))
{
if(tag == "")
continue;
let span = document.createElement("span");
span.dataset.tag = tag;
span.classList.add("word");
if(tag != "or")
span.classList.add("tag");
// Split off - prefixes to look up the translation, then add it back.
let prefix_and_tag = helpers.split_tag_prefixes(tag);
let translated_tag = translated_tags[prefix_and_tag[1]];
if(translated_tag)
translated_tag = prefix_and_tag[0] + translated_tag;
span.innerText = translated_tag || tag;
if(translated_tag)
span.dataset.translated_tag = translated_tag;
tag_container.appendChild(span);
}
var url = page_manager.singleton().get_url_for_tag_search(tag_search, ppixiv.location);
entry.querySelector("A.search").href = url;
return entry;
}
// Populate the tag dropdown.
//
// This is async, since IndexedDB is async. (It shouldn't be. It's an overcorrection.
// Network APIs should be async, but local I/O should not be forced async.) If another
// call to populate_dropdown() is made before this completes or cancel_populate_dropdown
// cancels it, return false. If it completes, return true.
async populate_dropdown()
{
// If another populate_dropdown is already running, cancel it and restart.
this.cancel_populate_dropdown();
// Set populate_dropdown_abort to an AbortController for this call.
let abort_controller = this.populate_dropdown_abort = new AbortController();
let abort_signal = abort_controller.signal;
var tag_searches = settings.get("recent-tag-searches") || [];
// Individually show all tags in search history.
var all_tags = {};
for(let tag_search of tag_searches)
{
for(let tag of helpers.split_search_tags(tag_search))
{
tag = helpers.split_tag_prefixes(tag)[1];
// Ignore "or".
if(tag == "" || tag == "or")
continue;
all_tags[tag] = true;
}
}
all_tags = Object.keys(all_tags);
let translated_tags = await tag_translations.get().get_translations(all_tags, "en");
// Sort tags by their translation.
all_tags.sort((lhs, rhs) => {
if(translated_tags[lhs]) lhs = translated_tags[lhs];
if(translated_tags[rhs]) rhs = translated_tags[rhs];
return lhs.localeCompare(rhs);
});
// Check if we were aborted while we were loading tags.
if(abort_signal && abort_signal.aborted)
{
console.log("populate_dropdown_inner aborted");
return false;
}
var list = this.tag_dropdown.querySelector(".input-dropdown-list");
helpers.remove_elements(list);
for(var tag of all_tags)
{
var entry = this.create_entry(tag, translated_tags);
list.appendChild(entry);
}
this.refresh_highlighted_tags();
return true;
}
cancel_populate_dropdown()
{
if(this.populate_dropdown_abort == null)
return;
this.populate_dropdown_abort.abort();
}
refresh_highlighted_tags()
{
let tags = helpers.split_search_tags(this.input_element.value);
var list = this.tag_dropdown.querySelector(".input-dropdown-list");
for(let tag_entry of list.querySelectorAll("[data-tag]"))
{
let tag = tag_entry.dataset.tag;
let tag_selected = tags.indexOf(tag) != -1;
helpers.set_class(tag_entry, "highlight", tag_selected);
}
}
// Add or remove tag from the tag search. This doesn't affect -tag searches.
toggle_tag(tag)
{
console.log("Toggle tag:", tag);
let tags = helpers.split_search_tags(this.input_element.value);
let idx = tags.indexOf(tag);
if(idx != -1)
tags.splice(idx, 1);
else
tags.push(tag);
this.input_element.value = tags.join(" ");
this.refresh_highlighted_tags();
// Navigate to the edited search immediately. Don't add these to history, since it
// spams navigation history.
helpers.set_page_url(page_manager.singleton().get_url_for_tag_search(this.input_element.value, ppixiv.location), false);
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/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(illust_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 illust_id of illust_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(illust_id);
if(thumb_info == null)
continue;
data[illust_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 illust_ids for recently viewed illusts, most recent first.
async get_recent_illust_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 illust IDs if we have it.
async get_thumbnail_info(illust_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 illust_id of illust_ids)
promises[illust_id] = key_storage.async_store_get(store, illust_id);
await Promise.all(Object.values(promises));
let results = [];
for(let illust_id of illust_ids)
{
let entry = await promises[illust_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;
}
async get_stored_illust_thumbnail_info(illust_ids)
{
return await this.db.db_op(async (db) => {
let store = this.db.get_store(db);
});
}
// Clear history.
async clear()
{
return await this.db.clear();
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/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)
{
let info = await this.get_tag_info(tags);
let result = {};
for(let tag of tags)
{
if(info[tag] == null || info[tag].translation == null)
continue;
// Skip this tag if we don't have a translation for this language.
let translation = info[tag].translation[language];
if(translation == null)
continue;
result[tag] = translation;
}
return result;
}
// Given a tag search, return a translated search.
async translate_tag_list(tags, language)
{
// Pull out individual tags, removing -prefixes.
let split_tags = helpers.split_search_tags(tags);
let tag_list = [];
for(let tag of split_tags)
{
let prefix_and_tag = helpers.split_tag_prefixes(tag);
tag_list.push(prefix_and_tag[1]);
}
// Get translations.
let translated_tags = await this.get_translations(tag_list, language);
// Put the search back together.
let result = [];
for(let one_tag of split_tags)
{
let prefix_and_tag = helpers.split_tag_prefixes(one_tag);
let prefix = prefix_and_tag[0];
let tag = prefix_and_tag[1];
if(translated_tags[tag])
tag = translated_tags[tag];
result.push(prefix + tag);
}
return result;
}
// A shortcut to retrieve one translation. If no translation is available, returns the
// original tag.
async get_translation(tag, language="en")
{
let translated_tags = await tag_translations.get().get_translations([tag], "en");
if(translated_tags[tag])
return translated_tags[tag];
else
return tag;
}
// Set the innerText of an element to tag, translating it if possible.
//
// This is async to look up the tag translation, but it's safe to release this
// without awaiting.
async set_translated_tag(element, tag)
{
let original_tag = tag;
element.dataset.tag = original_tag;
tag = await this.get_translation(tag);
// Stop if another call was made here while we were async.
if(element.dataset.tag != original_tag)
return;
element.innerText = tag;
}
}
// This updates the pp_tag_translations IDB store to ppixiv-tag-translations.
//
// The older database code kept the database open all the time. That's normal in every
// database in the world, except for IDB where it'll wedge everything (even the Chrome
// inspector window) if you try to change object stores. Read it out and write it to a
// new database, so users upgrading don't have to restart their browser to get tag translations
// back.
//
// This doesn't delete the old database, since for some reason that fires versionchange, which
// might make other tabs misbehave since they're not expecting it. We can add some code to
// clean up the old database later on when we can assume everybody has done this migration.
ppixiv.update_translation_storage = class
{
static run()
{
let update = new this();
update.update();
}
constructor()
{
this.name = "pp_tag_translations";
}
async db_op(func)
{
let db = await this.open_database();
try {
return await func(db);
} finally {
db.close();
}
}
open_database()
{
return new Promise((resolve, reject) => {
let request = indexedDB.open("ppixiv");
request.onsuccess = e => { resolve(e.target.result); };
request.onerror = e => { resolve(null); };
});
}
async_store_get(store)
{
return new Promise((resolve, reject) => {
let request = store.getAll();
request.onsuccess = e => resolve(e.target.result);
request.onerror = reject;
});
}
async update()
{
// Firefox is missing indexedDB.databases, so Firefox users get to wait for
// tag translations to repopulate.
if(!indexedDB.databases)
return;
// If the ppixiv-tag-translations database exists, assume this migration has already been done.
// First see if the old database exists and the new one doesn't.
let found = false;
for(let db of await indexedDB.databases())
{
if(db.name == "ppixiv-tag-translations")
return;
if(db.name == "ppixiv")
found = true;
}
if(!found)
return;
console.log("Migrating translation database");
// Open the old db.
return await this.db_op(async (db) => {
if(db == null)
return;
let transaction = db.transaction(this.name, "readonly");
let store = transaction.objectStore(this.name);
let results = await this.async_store_get(store);
let translations = [];
for(let result of results)
{
try {
if(!result.tag || !result.translation)
continue;
let data = {
tag: result.tag,
translation: { },
};
if(result.romaji)
data.romaji = result.romaji;
let empty = true;
for(let lang in result.translation)
{
let translated = result.translation[lang];
if(!translated)
continue;
data.translation[lang] = translated;
empty = false;
}
if(empty)
continue;
translations.push(data);
} catch(e) {
// Tolerate errors, in case there's weird junk in this database.
console.log("Error updating tag:", result);
}
}
await tag_translations.get().add_translations(translations);
});
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/tag_translations.js
`;
ppixiv.resources["src/thumbnail_data.js"] = `"use strict";
// This handles batch fetching data for thumbnails.
//
// We can load a bunch of images at once with illust_list.php. This isn't enough to
// display the illustration, since it's missing a lot of data, but it's enough for
// displaying thumbnails (which is what the page normally uses it for).
ppixiv.thumbnail_data = class
{
constructor()
{
this.loaded_thumbnail_info = this.loaded_thumbnail_info.bind(this);
// Cached data:
this.thumbnail_data = { };
this.quick_user_data = { };
this.user_profile_urls = {};
// IDs that we're currently requesting:
this.loading_ids = {};
};
// Return the singleton, creating it if needed.
static singleton()
{
if(thumbnail_data._singleton == null)
thumbnail_data._singleton = new thumbnail_data();
return thumbnail_data._singleton;
};
// Return true if all thumbs in illust_ids have been loaded, or are currently loading.
//
// We won't start fetching IDs that aren't loaded.
are_all_ids_loaded_or_loading(illust_ids)
{
for(var illust_id of illust_ids)
{
if(this.thumbnail_data[illust_id] == null && !this.loading_ids[illust_id])
return false;
}
return true;
}
is_id_loaded_or_loading(illust_id)
{
return this.thumbnail_data[illust_id] != null || this.loading_ids[illust_id];
}
// Return thumbnail data for illud_id, or null if it's not loaded.
//
// The thumbnail data won't be loaded if it's not already available. Use get_thumbnail_info
// to load thumbnail data in batches.
get_one_thumbnail_info(illust_id)
{
return this.thumbnail_data[illust_id];
}
// Return thumbnail data for illust_ids, and start loading any requested IDs that aren't
// already loaded.
get_thumbnail_info(illust_ids)
{
var result = {};
var needed_ids = [];
for(var illust_id of illust_ids)
{
var data = this.thumbnail_data[illust_id];
if(data == null)
{
// If this is a user:user_id instead of an illust ID, make sure we don't request it
// as an illust ID.
if(illust_id.indexOf(":") != -1)
continue;
needed_ids.push(illust_id);
continue;
}
result[illust_id] = data;
}
// Load any thumbnail data that we didn't have.
if(needed_ids.length)
this.load_thumbnail_info(needed_ids);
return result;
}
// Load thumbnail info for the given list of IDs.
async load_thumbnail_info(illust_ids)
{
// Make a list of IDs that we're not already loading.
var ids_to_load = [];
for(var id of illust_ids)
if(this.loading_ids[id] == null)
ids_to_load.push(id);
if(ids_to_load.length == 0)
return;
for(var id of ids_to_load)
this.loading_ids[id] = true;
// There's also
//
// https://www.pixiv.net/ajax/user/user_id/profile/illusts?ids[]=1&ids[]=2&...
//
// which is used by newer pages. That's useful since it tells us whether each
// image is bookmarked. However, it doesn't tell us the user's name or profile image
// URL, and for some reason it's limited to a particular user. Hopefully they'll
// have an updated generic illustration lookup call if they ever update the
// regular search pages, and we can switch to it then.
var result = await helpers.rpc_get_request("/rpc/illust_list.php", {
illust_ids: ids_to_load.join(","),
// Specifying this gives us 240x240 thumbs, which we want, rather than the 150x150
// ones we'll get if we don't (though changing the URL is easy enough too).
page: "discover",
// We do our own muting, but for some reason this flag is needed to get bookmark info.
exclude_muted_illusts: 1,
});
this.loaded_thumbnail_info(result, "illust_list");
}
// Get the mapping from /ajax/user/id/illusts/bookmarks to illust_list.php's keys.
get thumbnail_info_map_illust_list()
{
if(this._thumbnail_info_map_illust_list != null)
return this._thumbnail_info_map_illust_list;
this._thumbnail_info_map_illust_list = [
["illust_id", "id"],
["url", "url"],
["tags", "tags"],
["illust_user_id", "userId"],
["illust_width", "width"],
["illust_height", "height"],
["illust_type", "illustType"],
["illust_page_count", "pageCount"],
["illust_title", "title"],
["user_profile_img", "profileImageUrl"],
["user_name", "userName"],
// 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", "title"],
["profile_img", "profileImageUrl"],
["user_name", "userName"],
["illust_upload_timestamp", "createDate"],
];
return this._thumbnail_info_map_ranking;
};
// Given a low-res thumbnail URL from thumbnail data, return a high-res thumbnail URL.
// If page isn't 0, return a URL for the given manga page.
get_high_res_thumbnail_url(url, page=0)
{
// Some random results on the user recommendations page also return this:
//
// /c/540x540_70/custom-thumb/img/.../12345678_custom1200.jpg
//
// Replace /custom-thumb/' with /img-master/ first, since it makes matching below simpler.
url = url.replace("/custom-thumb/", "/img-master/");
// path should look like
//
// /c/250x250_80_a2/img-master/img/.../12345678_square1200.jpg
//
// where 250x250_80_a2 is the resolution and probably JPEG quality. We want
// the higher-res thumbnail (which is "small" in the full image data), which
// looks like:
//
// /c/540x540_70/img-master/img/.../12345678_master1200.jpg
//
// The resolution field is changed, and "square1200" is changed to "master1200".
var url = new URL(url, ppixiv.location);
var path = url.pathname;
var re = /(\\/c\\/)([^\\/]+)(.*)(square1200|master1200|custom1200).jpg/;
var match = re.exec(path);
if(match == null)
{
console.warn("Couldn't parse thumbnail URL:", path);
return url.toString();
}
url.pathname = match[1] + "540x540_70" + match[3] + "master1200.jpg";
if(page != 0)
{
// Manga URLs end with:
//
// /c/540x540_70/custom-thumb/img/.../12345678_p0_master1200.jpg
//
// p0 is the page number.
url.pathname = url.pathname.replace("_p0_master1200", "_p" + page + "_master1200");
}
return url.toString();
}
// This is called when we have new thumbnail data available. thumb_result is
// an array of thumbnail items.
//
// This can come from a bunch of different places, which all return the same data, but
// each in a different way:
//
// name URL
// normal /ajax/user/id/illusts/bookmarks
// illust_list illust_list.php
// following bookmark_new_illust.php
// following search.php
// rankings ranking.php
//
// We map each of these to "normal".
//
// These have the same data, but for some reason everything has different names.
// Remap them to "normal", and check that all fields we expect exist, to make it
// easier to notice if something is wrong.
loaded_thumbnail_info(thumb_result, source)
{
if(thumb_result.error)
return;
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. Just 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
throw "Unrecognized source: " + source;
// 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 = this.get_high_res_thumbnail_url(remapped_thumb_info.url);
remapped_thumb_info.mangaPages = [];
for(let page = 0; page < remapped_thumb_info.pageCount; ++page)
{
let url = this.get_high_res_thumbnail_url(remapped_thumb_info.url, page);
remapped_thumb_info.mangaPages.push(url);
}
thumb_info = remapped_thumb_info;
// Store the data.
this.add_thumbnail_info(thumb_info);
var illust_id = thumb_info.id;
delete this.loading_ids[illust_id];
// Cache the user's profile URL. This lets us display it more quickly when we
// haven't loaded user info yet.
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;
}
// Broadcast that we have new thumbnail data available.
window.dispatchEvent(new Event("thumbnailsLoaded"));
};
// Store thumbnail info.
add_thumbnail_info(thumb_info)
{
var illust_id = thumb_info.id;
this.thumbnail_data[illust_id] = thumb_info;
}
is_muted(thumb_info)
{
if(muting.singleton.is_muted_user_id(thumb_info.illust_user_id))
return true;
if(muting.singleton.any_tag_muted(thumb_info.tags))
return true;
return false;
}
// This is a simpler form of thumbnail data for user info. This is just the bare minimum
// info we need to be able to show a user thumbnail on the search page. 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(user_data, source)
{
let data = null;
if(source == "following")
{
data = {
userId: user_data.userId,
userName: user_data.userName,
profileImageUrl: user_data.profileImageUrl,
};
}
else if(source == "recommendations")
{
data = {
userId: user_data.userId,
userName: user_data.name,
profileImageUrl: user_data.imageBig,
};
}
else if(source == "users_bookmarking_illust" || source == "user_search")
{
data = {
userId: user_data.user_id,
userName: user_data.user_name,
profileImageUrl: user_data.profile_img,
};
}
else
throw "Unknown source: " + source;
this.quick_user_data[data.userId] = data;
}
get_quick_user_data(user_id)
{
return this.quick_user_data[user_id];
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/thumbnail_data.js
`;
ppixiv.resources["src/manga_thumbnail_widget.js"] = `"use strict";
class scroll_handler
{
constructor(container)
{
this.container = container;
}
// Bring item into view. We'll also try to keep the next and previous items visible.
scroll_into_view(item)
{
// Make sure item is a direct child of the container.
if(item.parentNode != this.container)
{
console.error("Node", item, "isn't in scroller", this.container);
return;
}
// Scroll so the items to the left and right of the current thumbnail are visible,
// so you can tell whether there's another entry to scroll to. If we can't fit
// them, center the selection.
var scroller_left = this.container.getBoundingClientRect().left;
var left = item.offsetLeft - scroller_left;
if(item.previousElementSibling)
left = Math.min(left, item.previousElementSibling.offsetLeft - scroller_left);
var right = item.offsetLeft + item.offsetWidth - scroller_left;
if(item.nextElementSibling)
right = Math.max(right, item.nextElementSibling.offsetLeft + item.nextElementSibling.offsetWidth - scroller_left);
var new_left = this.container.scrollLeft;
if(new_left > left)
new_left = left;
if(new_left + this.container.offsetWidth < right)
new_left = right - this.container.offsetWidth;
this.container.scrollLeft = new_left;
// If we didn't fit the previous and next entries, there isn't enough space. This
// might be a wide thumbnail or the window might be very narrow. Just center the
// selection. Note that we need to compare against the value we assigned and not
// read scrollLeft back, since the API is broken and reads back the smoothed value
// rather than the target we set.
if(new_left > left ||
new_left + this.container.offsetWidth < right)
{
this.center_item(item);
}
}
// Scroll the given item to the center.
center_item(item)
{
var scroller_left = this.container.getBoundingClientRect().left;
var left = item.offsetLeft - scroller_left;
left += item.offsetWidth/2;
left -= this.container.offsetWidth / 2;
this.container.scrollLeft = left;
}
/* Snap to the target position, cancelling any smooth scrolling. */
snap()
{
this.container.style.scrollBehavior = "auto";
if(this.container.firstElementChild)
this.container.firstElementChild.getBoundingClientRect();
this.container.getBoundingClientRect();
this.container.style.scrollBehavior = "";
}
};
ppixiv.manga_thumbnail_widget = class
{
constructor(container)
{
this.onclick = this.onclick.bind(this);
this.onmouseenter = this.onmouseenter.bind(this);
this.onmouseleave = this.onmouseleave.bind(this);
this.check_image_loads = this.check_image_loads.bind(this);
this.window_onresize = this.window_onresize.bind(this);
window.addEventListener("resize", this.window_onresize);
this.container = container;
this.container.addEventListener("click", this.onclick);
this.container.addEventListener("mouseenter", this.onmouseenter);
this.container.addEventListener("mouseleave", this.onmouseleave);
this.cursor = document.createElement("div");
this.cursor.classList.add("thumb-list-cursor");
this.scroll_box = this.container.querySelector(".manga-thumbnails");
this.scroller = new scroll_handler(this.scroll_box);
this.visible = false;
this.set_illust_info(null);
}
// Both Firefox and Chrome have some nasty layout bugs when resizing the window,
// causing the flexbox and the images inside it to be incorrect. Work around it
// by forcing a refresh.
window_onresize(e)
{
this.refresh();
}
onmouseenter(e)
{
this.hovering = true;
this.refresh_visible();
}
onmouseleave(e)
{
this.stop_hovering();
}
stop_hovering()
{
this.hovering = false;
this.refresh_visible();
}
refresh_visible()
{
this.visible = this.hovering;
}
get visible()
{
return this.container.classList.contains("visible");
}
set visible(visible)
{
if(visible == this.visible)
return;
helpers.set_class(this.container, "visible", visible);
if(!visible)
this.stop_hovering();
}
onclick(e)
{
var arrow = e.target.closest(".manga-thumbnail-arrow");
if(arrow != null)
{
e.preventDefault();
e.stopPropagation();
var left = arrow.dataset.direction == "left";
console.log("scroll", left);
var new_page = this.current_page + (left? -1:+1);
if(new_page < 0 || new_page >= this.entries.length)
return;
main_controller.singleton.show_illust(this.illust_info.illustId, {
page: new_page,
});
/*
var entry = this.entries[new_page];
if(entry == null)
return;
this.scroller.scroll_into_view(entry);
*/
return;
}
var thumb = e.target.closest(".manga-thumbnail-box");
if(thumb != null)
{
e.preventDefault();
e.stopPropagation();
var new_page = parseInt(thumb.dataset.page);
main_controller.singleton.show_illust(this.illust_info.illustId, {
page: new_page,
});
return;
}
}
set_illust_info(illust_info)
{
if(illust_info == this.illust_info)
return;
// Only display if we have at least two pages.
if(illust_info != null && illust_info.pageCount < 2)
illust_info = null;
// If we're not on a manga page, hide ourselves entirely, including the hover box.
this.container.hidden = illust_info == null;
this.illust_info = illust_info;
if(illust_info == null)
this.stop_hovering();
// Refresh the thumb images.
this.refresh();
// Start or stop check_image_loads if needed.
if(this.illust_info == null && this.check_image_loads_timer != null)
{
clearTimeout(this.check_image_loads_timer);
this.check_image_loads_timer = null;
}
this.check_image_loads();
}
snap_transition()
{
this.scroller.snap();
}
// This is called when the manga page is changed externally.
current_page_changed(page)
{
// Ignore page changes if we're not displaying anything.
if(this.illust_info == null)
return
this.current_page = page;
if(this.current_page == null)
return;
// Find the entry for the page.
var entry = this.entries[this.current_page];
if(entry == null)
{
console.error("Scrolled to unknown page", this.current_page);
return;
}
this.scroller.scroll_into_view(entry);
if(this.selected_entry)
helpers.set_class(this.selected_entry, "selected", false);
this.selected_entry = entry;
if(this.selected_entry)
{
helpers.set_class(this.selected_entry, "selected", true);
this.update_cursor_position();
}
}
update_cursor_position()
{
// Wait for images to know their size before positioning the cursor.
if(this.selected_entry == null || this.waiting_for_images || this.cursor.parentNode == null)
return;
// Position the cursor to the position of the selection.
this.cursor.style.width = this.selected_entry.offsetWidth + "px";
var scroller_left = this.scroll_box.getBoundingClientRect().left;
var base_left = this.cursor.parentNode.getBoundingClientRect().left;
var position_left = this.selected_entry.getBoundingClientRect().left;
var left = position_left - base_left;
this.cursor.style.left = left + "px";
}
// We can't update the UI properly until we know the size the thumbs will be,
// and the site doesn't tell us the size of manga pages (only the first page).
// Work around this by hiding until we have naturalWidth for all images, which
// will allow layout to complete. There's no event for this for some reason,
// so the only way to detect it is with a timer.
//
// This often isn't needed because of image preloading.
check_image_loads()
{
if(this.illust_info == null)
return;
this.check_image_loads_timer = null;
var all_images_loaded = true;
for(var img of this.container.querySelectorAll("img.manga-thumb"))
{
if(img.naturalWidth == 0)
all_images_loaded = false;
}
// If all images haven't loaded yet, check again.
if(!all_images_loaded)
{
this.waiting_for_images = true;
this.check_image_loads_timer = setTimeout(this.check_image_loads, 10);
return;
}
this.waiting_for_images = false;
// Now that we know image sizes and layout can update properly, we can update the cursor's position.
this.update_cursor_position();
}
refresh()
{
if(this.cursor.parentNode)
this.cursor.parentNode.removeChild(this.cursor);
var ul = this.container.querySelector(".manga-thumbnails");
helpers.remove_elements(ul);
this.entries = [];
if(this.illust_info == null)
return;
// Add left and right padding elements to center the list if needed.
var left_padding = document.createElement("div");
left_padding.style.flex = "1";
ul.appendChild(left_padding);
for(var page = 0; page < this.illust_info.pageCount; ++page)
{
var page_info = this.illust_info.mangaPages[page];
var url = page_info.urls.small;
var img = document.createElement("img");
var entry = helpers.create_from_template(".template-manga-thumbnail");
entry.dataset.page = page;
entry.querySelector("img.manga-thumb").src = url;
ul.appendChild(entry);
this.entries.push(entry);
}
var right_padding = document.createElement("div");
right_padding.style.flex = "1";
ul.appendChild(right_padding);
// Place the cursor inside the first entry, so it follows it around as we scroll.
this.entries[0].appendChild(this.cursor);
this.update_cursor_position();
}
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/manga_thumbnail_widget.js
`;
ppixiv.resources["src/page_manager.js"] = `"use strict";
// This handles:
//
// - Keeping track of whether we're active or not. If we're inactive, we turn off
// and let the page run normally.
// - Storing state in the address bar.
//
// We're active by default on illustration pages, and inactive by default on others.
//
// If we're active, we'll store our state in the hash as "#ppixiv/...". The start of
// the hash will always be "#ppixiv", so we can tell it's our data. If we're on a page
// where we're inactive by default, this also remembers that we've been activated.
//
// If we're inactive on a page where we're active by default, we'll always put something
// other than "#ppixiv" in the address bar. It doesn't matter what it is. This remembers
// that we were deactivated, and remains deactivated even if the user clicks an anchor
// in the page that changes the hash.
//
// If we become active or inactive after the page loads, we refresh the page.
//
// We have two sets of query parameters: args stored in the URL query, and args stored in
// the hash. For example, in:
//
// https://www.pixiv.net/bookmark.php?p=2#ppixiv?illust_id=1234
//
// our query args are p=2, and our hash args are illust_id=1234. We use query args to
// store state that exists in the underlying page, and hash args to store state that
// doesn't, so the URL remains valid for the actual Pixiv page if our UI is turned off.
ppixiv.page_manager = class
{
constructor()
{
this.window_popstate = this.window_popstate.bind(this);
window.addEventListener("popstate", this.window_popstate, true);
this.data_sources_by_canonical_url = {};
this.active = this._active_internal();
};
// Return the singleton, creating it if needed.
static singleton()
{
if(page_manager._singleton == null)
page_manager._singleton = new page_manager();
return page_manager._singleton;
};
// Return the data source for a URL, or null if the page isn't supported.
get_data_source_for_url(url)
{
// url is usually document.location, which for some reason doesn't have .searchParams.
var url = new unsafeWindow.URL(url);
url = helpers.get_url_without_language(url);
let first_part = helpers.get_page_type_from_url(url);
if(first_part == "artworks")
{
return data_sources.current_illust;
}
else if(first_part == "users")
{
// This is one of:
//
// /users/12345
// /users/12345/artworks
// /users/12345/illustrations
// /users/12345/manga
// /users/12345/bookmarks
// /users/12345/following
//
// All of these except for bookmarks are handled by data_sources.artist.
let mode = helpers.get_path_part(url, 2);
if(mode == "following")
return data_sources.follows;
if(mode != "bookmarks")
return data_sources.artist;
// Handle a special case: we're called by early_controller just to find out if
// the current page is supported or not. This happens before window.global_data
// exists, so we can't check if we're viewing our own bookmarks or someone else's.
// In this case we don't need to, since the caller just wants to see if we return
// a data source or not.
if(window.global_data == null)
return data_sources.bookmarks;
// If show-all=0 isn't in the hash, and we're not viewing someone else's bookmarks,
// we're viewing all bookmarks, so use data_sources.bookmarks_merged. Otherwise,
// use data_sources.bookmarks.
var args = new helpers.args(url);
var user_id = helpers.get_path_part(url, 1);
if(user_id == null)
user_id = window.global_data.user_id;
var viewing_own_bookmarks = user_id == window.global_data.user_id;
var both_public_and_private = viewing_own_bookmarks && args.hash.get("show-all") != "0";
return both_public_and_private? data_sources.bookmarks_merged:data_sources.bookmarks;
}
else if(url.pathname == "/new_illust.php" || url.pathname == "/new_illust_r18.php")
return data_sources.new_illust;
else if(url.pathname == "/bookmark_new_illust.php" || url.pathname == "/bookmark_new_illust_r18.php")
return data_sources.bookmarks_new_illust;
else if(url.pathname == "/history.php")
return data_sources.recent;
else if(first_part == "tags")
return data_sources.search;
else if(url.pathname == "/discovery")
return data_sources.discovery;
else if(url.pathname == "/discovery/users")
return data_sources.discovery_users;
else if(url.pathname == "/bookmark_detail.php")
{
// If we've added "recommendations" to the hash info, this was a recommendations link.
let args = new helpers.args(url);
if(args.hash.get("recommendations"))
return data_sources.related_illusts;
else
return data_sources.related_favorites;
}
else if(url.pathname == "/ranking.php")
return data_sources.rankings;
else if(url.pathname == "/search_user.php")
return data_sources.search_users;
else
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 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.parse_hash(ppixiv.location) != null && 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);
}
return url;
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/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/r108/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 illust_id that we're currently showing, and the ID that we want
// to speculatively load. We'll run loads in parallel, giving the current image's resources
// priority and cancelling loads when they're no longer needed.
//
// This doesn't handle thumbnail preloading. Those are small and don't really need to be
// cancelled, and since we don't fill the browser's load queue here, we shouldn't prevent
// thumbnails from being able to load.
// A base class for fetching a single resource:
class preloader
{
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)
{
super();
this.url = url;
}
// Start the fetch. This should only be called once.
async start()
{
await helpers.decode_image(this.url, this.abort_controller.signal);
}
}
// Load a resource with fetch.
class fetch_preloader extends preloader
{
constructor(url)
{
super();
this.url = url;
}
async start()
{
let request = await 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 {
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 illust_id the user is currently viewing. If illust_id is null, the user isn't
// viewing an image (eg. currently viewing thumbnails).
async set_current_image(illust_id, page)
{
if(this.current_illust_id == illust_id && this.current_illust_page == page)
return;
this.current_illust_id = illust_id;
this.current_illust_page = page;
this.current_illust_info = null;
if(this.current_illust_id == null)
return;
// Get the image data. This will often already be available.
let illust_info = await image_data.singleton().get_image_info(this.current_illust_id);
if(this.current_illust_id != illust_id || this.current_illust_info != null)
return;
// Stop if the illust was changed while we were loading.
if(this.current_illust_id != illust_id && this.current_illust_page != page)
return;
// Store the illust_info for current_illust_id.
this.current_illust_info = illust_info;
// Preload thumbnails.
this.preload_thumbs(illust_info);
this.check_fetch_queue();
}
// Set the illust_id we want to speculatively load, which is the next or previous image in
// the current search. If illust_id is null, we don't want to speculatively load anything.
// If page is -1, the caller wants to preload the last manga page.
async set_speculative_image(illust_id, page)
{
if(this.speculative_illust_id == illust_id && this.speculative_illust_page == page)
return;
this.speculative_illust_id = illust_id;
this.speculative_illust_page = page;
this.speculative_illust_info = null;
if(this.speculative_illust_id == null)
return;
// Get the image data. This will often already be available.
let illust_info = await image_data.singleton().get_image_info(this.speculative_illust_id);
if(this.speculative_illust_id != illust_id || this.speculative_illust_info != null)
return;
// Stop if the illust was changed while we were loading.
if(this.speculative_illust_id != illust_id && this.speculative_illust_page != page)
return;
// Store the illust_info for current_illust_id.
this.speculative_illust_info = illust_info;
// Preload thumbnails.
this.preload_thumbs(illust_info);
this.check_fetch_queue();
}
// See if we need to start or stop preloads. We do this when we have new illustration info,
// and when a fetch finishes.
check_fetch_queue()
{
// console.log("check queue:", this.current_illust_info != null, this.speculative_illust_info != null);
// Make a list of fetches that we want to be running, in priority order.
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_illust_page));
if(this.speculative_illust_info != null)
wanted_preloads = wanted_preloads.concat(this.create_preloaders_for_illust(this.speculative_illust_info, this.speculative_illust_page));
// 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, page)
{
// Don't precache muted images.
if(muting.singleton.any_tag_muted(illust_data.tags.tags))
return [];
if(muting.singleton.is_muted_user_id(illust_data.userId))
return [];
// If this is a video, preload the ZIP.
if(illust_data.illustType == 2)
{
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 page is -1, preload the last page.
if(page == -1)
page = illust_data.mangaPages.length-1;
// Otherwise, preload the images. Preload thumbs first, since they'll load
// much faster.
let results = [];
for(let page of illust_data.mangaPages)
results.push(new img_preloader(page.urls.small));
// Preload the requested page.
if(page < illust_data.mangaPages.length)
results.push(new img_preloader(illust_data.mangaPages[page].urls.original));
return results;
}
preload_thumbs(illust_info)
{
if(illust_info == null)
return;
// We're only interested in preloading thumbs for manga pages.
if(illust_info.pageCount < 2)
return;
// Preload thumbs directly rather than queueing, since they load quickly and
// this reduces flicker in the manga thumbnail bar.
let thumbs = [];
for(let page of illust_info.mangaPages)
thumbs.push(page.urls.small);
helpers.preload_images(thumbs);
}
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/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: 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
{
// 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(container)
{
this.container = container;
this.refresh();
this.container.querySelector(".close-button").addEventListener("click", (e) => { this.hide(); });
// Close if the container is clicked, but not if something inside the container is clicked.
this.container.addEventListener("click", (e) => {
if(e.target != this.container)
return;
this.hide();
});
// Hide on any state change.
window.addEventListener("popstate", (e) => {
this.hide();
});
this.show();
}
refresh()
{
let items_box = this.container.querySelector(".items");
// Not really needed, since our contents never change
helpers.remove_elements(items_box);
let item_template = document.body.querySelector(".template-version-history-item");
for(let update of _update_history)
{
let entry = helpers.create_from_template(item_template);
entry.querySelector(".rev").innerText = "r" + update.version;
entry.querySelector(".text").innerHTML = update.text;
items_box.appendChild(entry);
}
}
show()
{
this.container.hidden = false;
}
hide()
{
this.container.hidden = true;
}
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/whats_new.js
`;
ppixiv.resources["src/send_image.js"] = `"use strict";
// This handles sending images from one tab to another.
ppixiv.SendImage = class
{
// This is a singleton, so we never close this channel.
static send_image_channel = new BroadcastChannel("ppixiv:send-image");
// A UUID we use to identify ourself to other tabs:
static tab_id = this.create_tab_id();
static tab_id_tiebreaker = Date.now()
static create_tab_id(recreate=false)
{
// If we have a saved tab ID, use it.
if(!recreate && sessionStorage.ppixivTabId)
return sessionStorage.ppixivTabId;
// Make a new ID, and save it to the session. This helps us keep the same ID
// when we're reloaded.
sessionStorage.ppixivTabId = helpers.create_uuid();
return sessionStorage.ppixivTabId;
}
static known_tabs = {};
static initialized = false;
static init()
{
if(this.initialized)
return;
this.initialized = true;
this.broadcast_tab_info = this.broadcast_tab_info.bind(this);
window.addEventListener("unload", this.window_onunload.bind(this));
// Let other tabs know when the info we send in tab info changes. For resize, delay this
// a bit so we don't spam broadcasts while the user is resizing the window.
window.addEventListener("resize", (e) => {
if(this.broadcast_info_after_resize_timer != -1)
clearTimeout(this.broadcast_info_after_resize_timer);
this.broadcast_info_after_resize_timer = setTimeout(this.broadcast_tab_info, 250);
});
window.addEventListener("visibilitychange", this.broadcast_tab_info);
document.addEventListener("windowtitlechanged", this.broadcast_tab_info);
// Send on window focus change, so we update things like screenX/screenY that we can't
// monitor.
window.addEventListener("focus", this.broadcast_tab_info);
window.addEventListener("blur", this.broadcast_tab_info);
window.addEventListener("popstate", this.broadcast_tab_info);
SendImage.send_image_channel.addEventListener("message", this.received_message.bind(this));
this.broadcast_tab_info();
this.query_tabs();
this.install_quick_view();
}
// 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 "quick-view", to show the image temporarily,
// or "display", to navigate to it.
static async send_image(illust_id, page, tab_id, 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(illust_id);
let illust_data = image_data.singleton().get_image_info_sync(illust_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_id,
illust_id: illust_id,
page: page,
action: action, // "quick-view" or "display"
thumbnail_info: thumbnail_info,
illust_data: illust_data,
user_info: user_info,
}, true);
}
static received_message(e)
{
let data = e.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 to is null, a tab is sending a quick view preview. Show this preview if this
// tab is a quick view target and the document is visible. Otherwise, to is a tab
// ID, so only preview if it's us.
if(data.to == null)
{
if(settings.get("no_receive_quick_view") || document.hidden)
{
console.log("Not receiving quick view");
return;
}
}
else if(data.to != SendImage.tab_id)
return;
// 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], "normal");
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("quick-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 == "quick-view" || data.action == "display");
// Show the image.
main_controller.singleton.show_illust(data.illust_id, {
page: data.page,
quick_view: data.action == "quick-view",
source: "quick-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 illust_id = screen? screen.displayed_illust_id:null;
let page = screen? screen.displayed_illust_page:null;
let thumbnail_info = illust_id? thumbnail_data.singleton().get_one_thumbnail_info(illust_id):null;
let illust_data = illust_id? image_data.singleton().get_image_info_sync(illust_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,
illust_id: illust_id,
page: page,
// 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,
};
// Add any extra data we've been given.
for(let key in this.extra_data)
our_tab_info[key] = this.extra_data[key];
this.send_message(our_tab_info);
// Add us to our own known_tabs.
this.known_tabs[SendImage.tab_id] = our_tab_info;
}
// Allow adding extra data to tab info. This lets us include things like the image
// zoom position without having to propagate it around.
static extra_data = {};
static set_extra_data(key, data, send_immediately)
{
this.extra_data[key] = data;
if(send_immediately)
this.broadcast_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 }));
}
}
// This is called if something else changes the illust while we're in quick view. Send it
// as a quick-view instead.
static illust_change_during_quick_view(illust_id, page)
{
// This should only happen while we're in quick view.
console.assert(ppixiv.history.virtual);
SendImage.send_image(illust_id, page, null, "quick-view");
}
// 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 install_quick_view()
{
let setup = () => {
// Remove old event handlers.
if(this.quick_view_active)
{
this.quick_view_active.abort();
this.quick_view_active = null;
}
// Stop if quick view isn't enabled.
if(!settings.get("quick_view"))
return;
this.quick_view_active = new AbortController();
window.addEventListener("click", this.quick_view_window_onclick, { signal: this.quick_view_active.signal, capture: true });
new ppixiv.pointer_listener({
element: window,
button_mask: 0b11,
signal: this.quick_view_active.signal,
callback: this.quick_view_pointerevent,
});
};
// Set up listeners, and update them when the quick view setting changes.
setup();
settings.register_change_callback("quick_view", setup);
}
static quick_view_started(pointer_id)
{
// Hide the cursor, and capture the cursor to the document so it stays hidden.
document.body.style.cursor = "none";
this.captured_pointer_id = pointer_id;
document.body.setPointerCapture(this.captured_pointer_id);
// Pause thumbnail animations, so they don't keep playing while viewing an image
// in another tab.
document.body.classList.add("pause-thumbnail-animation");
// Listen to pointer movement during quick view.
window.addEventListener("pointermove", this.quick_view_window_onpointermove);
window.addEventListener("contextmenu", this.quick_view_window_oncontextmenu, { capture: true });
}
static quick_view_stopped()
{
if(this.captured_pointer_id != null)
{
document.body.releasePointerCapture(this.captured_pointer_id);
this.captured_pointer_id = null;
}
document.body.classList.remove("pause-thumbnail-animation");
window.removeEventListener("pointermove", this.quick_view_window_onpointermove);
window.removeEventListener("contextmenu", this.quick_view_window_oncontextmenu, { capture: true });
document.body.style.cursor = "";
}
static finalize_quick_view()
{
this.quick_view_stopped();
SendImage.send_message({ message: "send-image", action: "finalize", to: null }, true);
}
static quick_view_pointerevent = (e) =>
{
if(e.pressed && e.mouseButton == 0)
{
// See if the click is on an image search result.
let { illust_id, page } = main_controller.singleton.get_illust_at_element(e.target);
if(illust_id == null)
return;
e.preventDefault();
e.stopImmediatePropagation();
// This should never happen, but make sure we don't register duplicate pointermove events.
if(this.previewing_image)
return;
// Quick view this image.
this.previewing_image = true;
SendImage.send_image(illust_id, page, null, "quick-view");
this.quick_view_started(e.pointerId);
}
// Right-clicking while quick viewing an image locks the image, so it doesn't go away
// when the LMB is released.
if(e.pressed && e.mouseButton == 1)
{
if(!this.previewing_image)
return;
e.preventDefault();
e.stopImmediatePropagation();
this.finalize_quick_view();
}
// Releasing LMB while previewing an image stops previewing.
if(!e.pressed && e.mouseButton == 0)
{
if(!this.previewing_image)
return;
this.previewing_image = false;
e.preventDefault();
e.stopImmediatePropagation();
this.quick_view_stopped();
SendImage.send_message({ message: "send-image", action: "cancel", to: null }, true);
}
}
static quick_view_window_onclick = (e) =>
{
if(e.button != 0)
return;
// Work around one of the oldest design mistakes: cancelling mouseup doesn't prevent
// the resulting click. Check if this click was on an element that was handled by
// quick view, and cancel it if it was.
let { illust_id, page } = main_controller.singleton.get_illust_at_element(e.target);
if(illust_id == null)
return;
e.preventDefault();
e.stopImmediatePropagation();
}
// Work around another wonderful bug: while pointer lock is active, we don't get pointerdown
// events for *other* mouse buttons. That doesn't make much sense. Work around it by
// assuming RMB will fire contextmenu.
static quick_view_window_oncontextmenu = (e) =>
{
console.log("context", e.button);
e.preventDefault();
e.stopImmediatePropagation();
this.finalize_quick_view();
}
// This is only registered while we're quick viewing, to send mouse movements to
// anything displaying the image.
static quick_view_window_onpointermove = (e) =>
{
SendImage.send_message({
message: "preview-mouse-movement",
x: e.movementX,
y: e.movementY,
}, true);
}
};
// A context menu widget showing known tabs on the desktop to send images to.
ppixiv.send_image_widget = class extends ppixiv.illust_widget
{
constructor(container)
{
let contents = helpers.create_from_template(".template-popup-send-image");
container.appendChild(contents);
super(contents);
this.dropdown_list = this.container.querySelector(".list");
// Close the dropdown if the popup menu is closed.
new view_hidden_listener(this.container, (e) => { this.visible = false; });
// Refresh when the image data changes.
image_data.singleton().illust_modified_callbacks.register(this.refresh.bind(this));
}
get visible()
{
return this.container.classList.contains("visible");
}
set visible(value)
{
if(this.container.classList.contains("visible") == value)
return;
helpers.set_class(this.container, "visible", value);
if(value)
{
// Refresh when we're displayed.
this.refresh();
}
else
{
// Make sure we don't leave a tab highlighted if we're hidden.
this.unhighlight_tab();
}
}
// Stop highlighting a tab.
unhighlight_tab()
{
if(this.previewing_on_tab == null)
return;
// Stop previewing the tab.
SendImage.send_message({ message: "send-image", action: "cancel", to: this.previewing_on_tab }, true);
this.previewing_on_tab = null;
}
refresh()
{
// Clean out the old tab list.
var old_tab_entries = this.container.querySelectorAll(".tab-entry");
for(let entry of old_tab_entries)
entry.remove();
// Make sure the dropdown is hidden if we have no image.
if(this._illust_id == null)
this.visible = false;
if(!this.visible)
return;
// Start preloading the image and image data, so it gives it a head start to be cached
// when the other tab displays it. We don't need to wait for this to display our UI.
image_data.singleton().get_image_info(this._illust_id).then((illust_data) => {
helpers.preload_images([illust_data.urls.original]);
});
let tab_ids = Object.keys(SendImage.known_tabs);
// We'll create icons representing the aspect ratio of each tab. This is a quick way
// to identify tabs when they have different sizes. Find the max width and height of
// any tab, so we can scale relative to it.
let max_width = 1, max_height = 1;
let desktop_min_x = 999999, desktop_max_x = -999999;
let desktop_min_y = 999999, desktop_max_y = -999999;
let found_any = false;
for(let tab_id of tab_ids)
{
let info = SendImage.known_tabs[tab_id];
if(!info.visible)
continue;
desktop_min_x = Math.min(desktop_min_x, info.screen_x);
desktop_min_y = Math.min(desktop_min_y, info.screen_y);
desktop_max_x = Math.max(desktop_max_x, info.screen_x + info.window_width);
desktop_max_y = Math.max(desktop_max_y, info.screen_y + info.window_height);
max_width = Math.max(max_width, info.window_width);
max_height = Math.max(max_height, info.window_height);
if(tab_id != SendImage.tab_id)
found_any = true;
}
// If there are no tabs other than ourself, show the intro.
this.container.querySelector(".no-other-tabs").hidden = found_any;
if(!found_any)
{
this.dropdown_list.style.width = this.dropdown_list.style.height = "";
return;
}
let desktop_width = desktop_max_x - desktop_min_x;
let desktop_height = desktop_max_y - desktop_min_y;
// Scale the maximum dimension of the largest tab to a fixed size, and the other
// tabs relative to it, so we show the relative shape and dimensions of each tab.
let max_dimension = 400;
let scale_by = max_dimension / Math.max(desktop_width, desktop_height);
// Scale the overall size to fit.
desktop_width *= scale_by;
desktop_height *= scale_by;
let offset_x_by = -desktop_min_x * scale_by;
let offset_y_by = -desktop_min_y * scale_by;
// Set the size of the containing box.
this.dropdown_list.style.width = \`\${desktop_width}px\`;
this.dropdown_list.style.height = \`\${desktop_height}px\`;
// If the popup is off the screen, shift it in. We don't do this with the popup
// menu to keep buttons in the same relative positions all the time, but this popup
// tends to overflow.
{
this.container.style.margin = "0"; // reset
let [actual_pos_x, actual_pos_y] = helpers.get_relative_pos(this.dropdown_list, document.body);
let wanted_pos_x = helpers.clamp(actual_pos_x, 20, window.innerWidth-desktop_width-20);
let wanted_pos_y = helpers.clamp(actual_pos_y, 20, window.innerHeight-desktop_height-20);
let shift_window_x = wanted_pos_x - actual_pos_x;
let shift_window_y = wanted_pos_y - actual_pos_y;
this.container.style.marginLeft = \`\${shift_window_x}px\`;
this.container.style.marginTop = \`\${shift_window_y}px\`;
}
// Add an entry for each tab we know about.
for(let tab_id of tab_ids)
{
let info = SendImage.known_tabs[tab_id];
// For now, only show visible tabs.
if(!info.visible)
continue;
let entry = this.create_tab_entry(tab_id);
this.dropdown_list.appendChild(entry);
// Position this entry.
let left = info.screen_x * scale_by + offset_x_by;
let top = info.screen_y * scale_by + offset_y_by;
let width = info.window_width * scale_by;
let height = info.window_height * scale_by;
entry.style.left = \`\${left}px\`;
entry.style.top = \`\${top}px\`;
entry.style.width = \`\${width}px\`;
entry.style.height =\`\${height}px\`;
entry.style.display = "block";
}
}
create_tab_entry(tab_id)
{
let info = SendImage.known_tabs[tab_id];
let entry = helpers.create_from_template(".template-send-image-tab");
entry.dataset.tab_id = tab_id;
if(info.visible)
entry.classList.add("tab-visible");
// If this tab is our own window:
if(tab_id == SendImage.tab_id)
entry.classList.add("self");
// Set the image.
let img = entry.querySelector(".shown-image");
img.hidden = true;
let image_url = null;
if(info.illust_data)
{
image_url = info.illust_data.urls.small;
if(info.page > 0)
image_url = info.illust_data.mangaPages[info.page].urls.small;
}
else if(info.thumbnail_info)
{
image_url = info.thumbnail_info.url;
}
if(image_url && info.illust_screen_pos)
{
img.hidden = false;
img.src = image_url;
// Position the image in the same way it is in the tab. The container is the same
// dimensions as the window, so we can just copy the final percentages.
img.style.top = \`\${info.illust_screen_pos.top*100}%\`;
img.style.left = \`\${info.illust_screen_pos.left*100}%\`;
img.style.width = \`\${info.illust_screen_pos.width*100}%\`;
img.style.height = \`\${info.illust_screen_pos.height*100}%\`;
}
else
{
// Show the mock search image if we have no image URL.
entry.querySelector(".search-tab").hidden = false;
}
// We don't need mouse event listeners for ourself.
if(tab_id == SendImage.tab_id)
return entry;
entry.addEventListener("click", (e) => {
if(tab_id == SendImage.tab_id)
return;
// On click, send the image for display, and close the dropdown.
SendImage.send_image(this._illust_id, this._page, tab_id, "display");
this.visible = false;
this.previewing_on_tab = null;
});
entry.addEventListener("mouseenter", (e) => {
let entry = e.target.closest(".tab-entry");
if(!entry)
return;
SendImage.send_image(this._illust_id, this._page, tab_id, "quick-view");
this.previewing_on_tab = tab_id;
});
entry.addEventListener("mouseleave", (e) => {
let entry = e.target.closest(".tab-entry");
if(!entry)
return;
this.unhighlight_tab();
});
return entry;
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/send_image.js
`;
ppixiv.resources["src/main.js"] = `"use strict";
// This handles high-level navigation and controlling the different screens.
ppixiv.main_controller = class
{
// This is called by bootstrap at startup. Just create ourself.
static launch() { new this; }
static get singleton()
{
if(main_controller._singleton == null)
throw "main_controller isn't created";
return main_controller._singleton;
}
constructor()
{
if(main_controller._singleton != null)
throw "main_controller is already created";
main_controller._singleton = this;
this.initial_setup();
}
async initial_setup()
{
// If we're not active, just see if we need to add our button, and stop without messing
// around with the page more than we need to.
if(!page_manager.singleton().active)
{
console.log("ppixiv is currently disabled");
await helpers.wait_for_content_loaded();
this.setup_disabled_ui();
return;
}
console.log("ppixiv setup");
// Install polyfills. Make sure we only do this if we're active, so we don't
// inject polyfills into Pixiv when we're not active.
install_polyfills();
// Run cleanup_environment. This will try to prevent the underlying page scripts from
// making network requests or creating elements, and apply other irreversible cleanups
// that we don't want to do before we know we're going to proceed.
helpers.cleanup_environment();
this.temporarily_hide_document();
// Wait for DOMContentLoaded to continue.
await helpers.wait_for_content_loaded();
// Continue with full initialization.
await this.setup();
}
// This is where the actual UI starts.
async setup()
{
console.log("ppixiv controller setup");
this.onkeydown = this.onkeydown.bind(this);
this.redirect_event_to_screen = this.redirect_event_to_screen.bind(this);
this.window_onclick_capture = this.window_onclick_capture.bind(this);
this.window_onpopstate = this.window_onpopstate.bind(this);
// Create the page manager.
page_manager.singleton();
// Run any one-time settings migrations.
settings.migrate();
// Migrate the translation database. We don't need to wait for this.
update_translation_storage.run();
// 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 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;
this.current_history_index = helpers.current_history_state_index();
// If the URL hash doesn't start with #ppixiv, the page was loaded with the base Pixiv
// URL, and we're active by default. Add #ppixiv to the URL. If we don't do this, we'll
// still work, but none of the URLs we create will have #ppixiv, so we won't handle navigation
// directly and the page will reload on every click. Do this before we create any of our
// UI, so our links inherit the hash.
if(helpers.parse_hash(ppixiv.location) == null)
{
// 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;
// Add binary resources as CSS styles.
helpers.add_style("noise-background", \`body .noise-background { background-image: url("\${resources['resources/noise.png']}"); };\`);
helpers.add_style("light-noise-background", \`body.light .noise-background { background-image: url("\${resources['resources/noise-light.png']}"); };\`);
// Add the main CSS style.
helpers.add_style("main", resources['resources/main.css']);
// Load image resources into blobs.
await this.load_resource_blobs();
// Create the page from our HTML resource.
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;
// Create the popup menu handler.
this.context_menu = new main_context_menu(document.body);
// Create the main progress bar.
this.progress_bar = new progress_bar(this.container.querySelector(".loading-progress-bar"));
// Create the screens.
this.screen_search = new screen_search(this.container.querySelector(".screen-search-container"));
this.screen_illust = new screen_illust(this.container.querySelector(".screen-illust-container"));
this.screen_manga = new screen_manga(this.container.querySelector(".screen-manga-container"));
SendImage.init();
this.screens = {
search: this.screen_search,
illust: this.screen_illust,
manga: this.screen_manga,
};
// Create the data source for this page.
this.set_current_data_source("initialization");
};
window_onpopstate(e)
{
// A special case for the bookmarks data source. It changes its page in the URL to mark
// how far the user has scrolled. We don't want this to trigger a data source change.
if(this.temporarily_ignore_onpopstate)
{
console.log("Not navigating for internal page change");
return;
}
// Set the current data source and state.
this.set_current_data_source(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);
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_illust_id = old_screen? old_screen.displayed_illust_id:null;
var old_illust_page = old_screen? old_screen.displayed_illust_page:null;
// Get the current data source. If we've already created it, this will just return
// the same object and not create a new one.
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 args = helpers.args.location;
let wanted_page = this.data_source.get_start_page(args);
let lowest_page = data_source.id_list.get_lowest_loaded_page();
let highest_page = data_source.id_list.get_highest_loaded_page();
if(wanted_page < lowest_page || wanted_page > highest_page)
{
// This works the same as refresh_current_data_source above.
console.log("Resetting data source to an unavailable page:", lowest_page, wanted_page, highest_page);
data_source = page_manager.singleton().create_data_source_for_url(ppixiv.location, true);
}
}
// If the data source is changing, set it up.
if(this.data_source != data_source)
{
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();
}
// 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");
var illust_id = data_source.get_current_illust_id();
var manga_page = args.hash.has("page")? parseInt(args.hash.get("page"))-1:0;
// If we're on search, we don't care what image is current. Clear illust_id so we
// tell context_menu that we're not viewing anything, so it disables bookmarking.
if(new_screen_name == "search")
illust_id = null;
console.log("Loading data source. Screen:", new_screen_name, "Cause:", cause, "URL:", ppixiv.location.href);
// 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.illust_id = illust_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;
// 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,
illust_id: illust_id,
page: manga_page,
navigation_cause: cause,
restore_history: restore_history,
});
}
// Dismiss any message when toggling between screens.
if(screen_changing)
message_widget.singleton.hide();
// If we're enabling the thumbnail, pulse the image that was just being viewed (or
// loading to be viewed), to make it easier to find your place.
if(new_screen_name == "search" && old_illust_id != null)
this.screen_search.pulse_thumbnail(old_illust_id);
// Are we navigating forwards or back?
var new_history_index = helpers.current_history_state_index();
var navigating_forwards = cause == "history" && new_history_index > this.current_history_index;
this.current_history_index = new_history_index;
// Handle scrolling for the new state.
//
// We could do this better with history.state (storing each state's scroll position would
// allow it to restore across browser sessions, and if the same data source is multiple
// places in history). Unfortunately there's no way to update history.state without
// calling history.replaceState, which is slow and causes jitter. history.state being
// read-only is a design bug in the history API.
if(cause == "navigation")
{
// If this is an initial navigation, eg. from a user clicking a link to a search, always
// scroll to the top. If this data source exists previously in history, we don't want to
// restore the scroll position from back then.
// console.log("Scroll to top for new search");
new_screen.scroll_to_top();
}
else if(cause == "leaving-virtual")
{
// We're backing out of a virtual URL used for quick view. Don't change the scroll position.
new_screen.restore_scroll_position();
}
else if(navigating_forwards)
{
// On browser history forwards, try to restore the scroll position.
// console.log("Restore scroll position for forwards navigation");
new_screen.restore_scroll_position();
}
else if(screen_changing && old_illust_id != null)
{
// If we're navigating backwards or toggling, and we're switching from the image UI to thumbnails,
// try to scroll the search screen to the image that was displayed. Otherwise, tell
// it to restore any scroll position saved in the data source.
// console.log("Scroll to", old_illust_id, old_illust_page);
new_screen.scroll_to_illust_id(old_illust_id, old_illust_page);
}
else
{
new_screen.restore_scroll_position();
}
}
show_data_source_specific_elements()
{
// Show UI elements with this data source in their data-datasource attribute.
var data_source_name = this.data_source.name;
for(var node of this.container.querySelectorAll(".data-source-specific[data-datasource]"))
{
var data_sources = node.dataset.datasource.split(" ");
var show_element = data_sources.indexOf(data_source_name) != -1;
node.hidden = !show_element;
}
}
// Show an illustration by ID.
//
// This actually just sets the history URL. We'll do the rest of the work in popstate.
show_illust(illust_id, {page, add_to_history=false, screen="illust", quick_view=false, source=""}={})
{
console.assert(illust_id != null, "Invalid illust_id", illust_id);
let args = helpers.args.location;
// If something else is navigating us in the middle of quick-view, such as changing
// the page with the mousewheel, let SendImage handle it. It'll treat it as a quick
// view and we'll end up back here with quick_view true. Don't do this if this is
// already coming from quick view.
if(args.hash.has("quick-view") && !quick_view && source != "quick-view")
{
console.log("Illust change during quick view");
SendImage.illust_change_during_quick_view(illust_id, page);
return;
}
// Update the URL to display this illust_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_illust_id(illust_id, args);
// Remove any leftover page from the current illust. We'll load the default.
if(page == null)
args.hash.delete("page");
else
args.hash.set("page", page + 1);
if(quick_view)
{
args.hash.set("virtual", "1");
args.hash.set("quick-view", "1");
}
else
{
args.hash.delete("virtual");
args.hash.delete("quick-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)
{
args.hash.set("view", screen);
// If we're going to the search or manga page, remove the page.
// If we're going to the manga page, remove just the page.
if(screen == "search" || screen == "manga")
args.hash.delete("page");
if(screen == "search")
args.hash.delete("illust_id");
}
// Navigate out.
//
// This navigates from the illust page to the manga page (for multi-page posts) or search, and
// from the manga page to search.
//
// This is similar to browser back, but allows moving up to the search even for new tabs. It
// would be better for this to integrate with browser history (just browser back if browser back
// is where we're going), but for some reason you can't view history state entries even if they're
// on the same page, so there's no way to tell where History.back() would take us.
get navigate_out_label()
{
let target = this.displayed_screen?.navigate_out_target;
switch(target)
{
case "manga": return "page list";
case "search": return "search";
default: return null;
}
}
navigate_out()
{
let new_page = this.displayed_screen?.navigate_out_target;
if(new_page == null)
return;
// If the user clicks "return to search" while on data_sources.current_illust, go somewhere
// else instead, since that viewer never has any search results.
if(new_page == "search" && this.data_source instanceof data_sources.current_illust)
{
let args = new helpers.args("/bookmark_new_illust.php#ppixiv", ppixiv.location);
helpers.set_page_url(args, true /* add_to_history */, "out");
return;
}
// Update the URL to mark whether thumbs are displayed.
let args = helpers.args.location;
this._set_active_screen_in_url(args, new_page);
helpers.set_page_url(args, true /* add_to_history */, "out");
}
// This captures clicks at the window level, allowing us to override them.
//
// When the user left clicks on a link that also goes into one of our screens,
// rather than loading a new page, we just set up a new data source, so we
// don't have to do a full navigation.
//
// This only affects left clicks (middle clicks into a new tab still behave
// normally).
window_onclick_capture(e)
{
// Only intercept regular left clicks.
if(e.button != 0 || e.metaKey || e.ctrlKey || e.altKey)
return;
if(!(e.target instanceof Element))
return;
// Look up from the target for a link.
var a = e.target.closest("A");
if(a == null)
return;
// If this isn't a #ppixiv URL, let it run normally.
var url = new URL(a.href, document.href);
var is_ppixiv_url = helpers.parse_hash(url) != null;
if(!is_ppixiv_url)
return;
// Stop all handling for this link.
e.preventDefault();
e.stopImmediatePropagation();
// Search links to images always go to /artworks/#, but if they're clicked in-page we
// want to stay on the same search and just show the image, so handle them directly.
var url = new unsafeWindow.URL(url);
url = helpers.get_url_without_language(url);
if(url.pathname.startsWith("/artworks/"))
{
let parts = url.pathname.split("/");
let illust_id = parts[2];
let args = new helpers.args(a.href);
var page = args.hash.has("page")? parseInt(args.hash.get("page"))-1: null;
let screen = args.hash.has("view")? args.hash.get("view"):"illust";
this.show_illust(illust_id, {
screen: screen,
page: page,
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()
{
// Doing this sync works better, because it
console.log("Reloading page to get init data");
// 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(document.location.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)
{
// 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.adult);
}
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.set_muted_tags(muted_tags);
muting.singleton.set_muted_user_ids(muted_user_ids);
window.global_data = {
// Store the token for XHR requests.
csrf_token: csrf_token,
user_id: user_id,
include_r18: content_mode >= 1,
include_r18g: content_mode >= 2,
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.keyCode == 27) // 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 illust ID and the user ID on it.
let illust_element = element.closest("[data-illust-id]");
if(illust_element)
{
result.illust_id = parseInt(illust_element.dataset.illustId);
// If no page is present, set page to null rather than page 0. This distinguishes image
// search results which don't refer to a specific page from the manga page display. Don't
// use -1 for this, since that's used in some places to mean the last page.
result.page = illust_element.dataset.pageIdx == null? null:parseInt(illust_element.dataset.pageIdx);
}
let user_element = element.closest("[data-user-id]");
if(user_element)
result.user_id = parseInt(user_element.dataset.userId);
return result;
}
// Load PNG resources into blobs, so we don't copy the whole PNG into every
// place it's used.
async load_resource_blobs()
{
for(let [name, dataURL] of Object.entries(ppixiv.resources))
{
if(!name.endsWith(".png"))
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);
// If we're on a page that we don't support, like the top page, rewrite the link to switch to
// a page we do support.
if(!page_manager.singleton().available_for_url(ppixiv.location))
disabled_ui.querySelector("a").href = "/ranking.php?mode=daily#ppixiv";
document.body.appendChild(disabled_ui);
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);
});
}
};
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/main.js
`;
// Note that this file doesn't use strict, because JS language developers remove
// useful features without a second thought. "with" may not be used often, but
// it's an important part of the language.
(() => {
// If we're in a release build, we're inside
// (function () {
// with(this)
// {
// ...
// }
// }.exec({});
//
// The empty {} object is our environment. It can be assigned to as "this" at the
// top level of scripts, and it's included in scope using with(this) so it's searched
// as a global scope.
//
// If we're in a debug build, this script runs standalone, and we set up the environment
// here.
// Our source files are stored as text, so we can attach sourceURL to them to give them
// useful filenames. "this" is set to the ppixiv context, and we load them out here so
// we don't have many locals being exposed as globals during the eval. We also need to
// do this out here in order ot use with.
let _load_source_file = function(__pixiv, __source) {
const ppixiv = __pixiv;
with(ppixiv)
{
return eval(__source);
}
};
new class
{
constructor(env)
{
// If this is an iframe, don't do anything.
if(window.top != window.self)
return;
// Don't activate for things like sketch.pixiv.net.
if(window.location.hostname != "www.pixiv.net")
return;
console.log("ppixiv bootstrap");
// If env is the window, this script was run directly, which means this is a
// development build and we need to do some extra setup. If this is a release build,
// the environment will be set up already.
if(env === window)
this.devel_setup();
else
this.env = env;
this.launch();
}
devel_setup()
{
// In a development build, our source and binary assets are in @resources, and we need
// to pull them out into an environment manually.
let env = {};
env.resources = {};
env.resources["output/setup.js"] = JSON.parse(GM_getResourceText("output/setup.js"));
let setup = env.resources["output/setup.js"];
let source_list = setup.source_files;
// Add the file containing binary resources to the list.
source_list.unshift("output/resources.js");
for(let path of source_list)
{
// Load the source file.
let source = GM_getResourceText(path);
if(source == null)
{
// launch() will show an error for this, so don't do it here too.
continue;
}
// Add sourceURL to each file, so they show meaningful filenames in logs.
// Since we're loading the files as-is and line numbers don't change, we
// don't need a source map.
//
// This uses a path that pretends to be on the same URl as the site, which
// seems to be needed to make VS Code map the paths correctly.
source += "\n";
source += `//# sourceURL=${document.location.origin}/ppixiv/${path}\n`;
env.resources[path] = source;
}
this.env = env;
}
launch()
{
let setup = this.env.resources["output/setup.js"];
let source_list = setup.source_files;
unsafeWindow.ppixiv = this.env;
// Load each source file.
for(let path of source_list)
{
let source = this.env.resources[path];
if(!source)
{
console.error("Source file missing:", path);
continue;
}
_load_source_file(this.env, source);
}
// Create the main controller.
this.env.main_controller.launch();
}
}(this);
})();
}
}).call({});