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 temp view and don't affect the data source.
args.hash.delete("virtual");
args.hash.delete("temp-view");
// This is for overriding muting.
args.hash.delete("view-muted");
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.previewUrls[0];
}
}
}
// Artist suggestions take a random sample of followed users, and query suggestions from them.
// The followed user list normally comes from /discovery/users.
//
// This can also be used to view recommendations based on a specific user. Note that if we're
// doing this, we don't show things like the artist's avatar in the corner, so it doesn't look
// like the images we're showing are by that user.
ppixiv.data_sources.discovery_users = class extends data_source
{
get name() { return "discovery_users"; }
constructor(url)
{
super(url);
let args = new helpers.args(this.url);
let user_id = args.hash.get("user_id");
if(user_id != null)
this.showing_user_id = user_id;
this.original_url = url;
this.seen_user_ids = {};
}
get users_per_page() { return 20; }
get estimated_items_per_page()
{
let illusts_per_user = this.showing_user_id != null? 3:5;
return this.users_per_page + (users_per_page * illusts_per_user);
}
async load_page_internal(page)
{
if(this.showing_user_id != null)
{
// Make sure the user info is loaded.
this.user_info = await image_data.singleton().get_user_info_full(this.showing_user_id);
// Update to refresh our page title, which uses user_info.
this.call_update_listeners();
}
// Get suggestions. Each entry is a user, and contains info about a small selection of
// images.
let result;
if(this.showing_user_id != null)
{
result = await helpers.get_request(\`/ajax/user/\${this.showing_user_id}/recommends\`, {
userNum: this.users_per_page,
workNum: 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_container.hidden = false;
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);
if(this.viewing_own_bookmarks())
{
// This request includes each bookmark's tags. Register those with image_data,
// so the bookmark tag dropdown can display tags more quickly.
for(let illust of result.body.works)
{
let bookmark_id = illust.bookmarkData.id;
let tags = result.body.bookmarkTags[bookmark_id] || [];
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 = "Untagged";
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_container.hidden = this.viewing_own_bookmarks();
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 = body.relatedTags;
this.call_update_listeners();
}
// Add translations.
let translations = [];
for(let tag of Object.keys(body.tagTranslation))
{
translations.push({
tag: tag,
translation: body.tagTranslation[tag],
});
}
tag_translations.get().add_translations(translations);
// /tag/TAG/illustrations returns results in body.illust.
// /tag/TAG/artworks returns results in body.illustManga.
// /tag/TAG/manga returns results in body.manga.
let illusts = body.illust || body.illustManga || body.manga;
illusts = illusts.data;
// Populate thumbnail data with this data.
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(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)
{
if(!this.viewing_self)
{
thumbnail_view.avatar_container.hidden = false;
thumbnail_view.avatar_widget.set_user_id(this.viewing_user_id);
}
// The public/private button only makes sense when viewing your own follows.
var public_private_button_container = container.querySelector(".follows-public-private");
public_private_button_container.hidden = !this.viewing_self;
this.set_item(container, "public-follows", {rest: "show"}, {rest: "show"});
this.set_item(container, "private-follows", {rest: "hide"}, {rest: "show"});
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 == "Untagged")
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.previewUrls[0];
}
}
get page_title()
{
return "Similar Bookmarks";
};
get_displaying_text()
{
if(this.illust_info)
return "Users who bookmarked " + this.illust_info.illustTitle;
else
return "Users who bookmarked image";
};
}
ppixiv.data_sources.search_users = class extends data_source_from_page
{
get name() { return "search-users"; }
get 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, "internal");
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/r111/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/r111/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/r111/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 Promise.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);
// Switch from i.pximg.net to i-cf.pximg.net, which is much faster outside of Japan.
for(let [key, url] of Object.entries(illust_data.urls))
{
url = new URL(url);
helpers.adjust_image_url_hostname(url);
illust_data.urls[key] = url.toString();
}
// Add an array of thumbnail URLs.
illust_data.previewUrls = [];
for(let page = 0; page < illust_data.pageCount; ++page)
{
let url = helpers.get_high_res_thumbnail_url(illust_data.urls.small, page);
illust_data.previewUrls.push(url);
}
// Add a flattened tag list.
illust_data.tagList = [];
for(let tag of illust_data.tags.tags)
illust_data.tagList.push(tag.tag);
// If we're loading image info, we're almost definitely going to load the avatar, so
// start preloading it now.
let user_info = await user_info_promise;
if(user_info)
helpers.preload_images([user_info.imageBig]);
if(manga_promise != null)
{
var manga_info = await manga_promise;
illust_data.mangaPages = manga_info.body;
for(let page of illust_data.mangaPages)
{
for(let [key, url] of Object.entries(page.urls))
{
url = new URL(url);
helpers.adjust_image_url_hostname(url);
page.urls[key] = url.toString();
}
}
}
if(ugoira_promise != null)
{
var ugoira_result = await ugoira_promise;
illust_data.ugoiraMetadata = ugoira_result.body;
}
// If this is a single-page image, create a dummy single-entry mangaPages array. This lets
// us treat all images the same.
if(illust_data.pageCount == 1)
{
illust_data.mangaPages = [{
width: illust_data.width,
height: illust_data.height,
// Rather than just referencing illust_Data.urls, copy just the image keys that
// exist in the regular mangaPages list (no thumbnails).
urls: {
original: illust_data.urls.original,
regular: illust_data.urls.regular,
small: illust_data.urls.small,
}
}];
}
guess_image_url.get.add_info(illust_data);
// Store the image data.
this.image_data[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)
{
// If we know the image isn't bookmarked, we know there are no bookmark tags, so
// we can skip this.
let thumb = await thumbnail_data.singleton().get_or_load_illust_data(illust_id, false /* don't load */);
if(thumb && thumb.bookmarkData == null)
return [];
// 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);
}
// 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; }
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r111/src/image_data.js
`;
ppixiv.resources["src/on_click_viewer.js"] = `"use strict";
// View img fullscreen. Clicking the image will zoom it to its original size and scroll
// it around.
//
// The image is always zoomed a fixed amount from its fullscreen size. This is generally
// more usable than doing things like zooming based on the native resolution.
ppixiv.on_click_viewer = class
{
constructor(container)
{
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.set_new_image = new SentinelGuard(this.set_new_image, this);
this.image_container = container;
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");
// This is aborted when we shut down to remove listeners.
this.event_shutdown = new AbortController();
window.addEventListener("blur", this.window_blur, { signal: this.event_shutdown.signal });
window.addEventListener("resize", this.onresize, { signal: this.event_shutdown.signal, capture: true });
this.image_container.addEventListener("dragstart", this.block_event, { signal: this.event_shutdown.signal });
this.image_container.addEventListener("selectstart", this.block_event, { signal: this.event_shutdown.signal });
this.pointer_listener = new ppixiv.pointer_listener({
element: this.image_container,
button_mask: 1,
signal: this.event_shutdown.signal,
callback: this.pointerevent,
});
// This is like pointermove, but received during quick view from the source tab.
window.addEventListener("quickviewpointermove", this.quick_view_pointermove, { signal: this.event_shutdown.signal });
}
// Load the given illust and page.
set_new_image = async(signal, {
url, preview_url,
width, height,
// "history" to restore from history, or "auto" to set automatically.
restore_position,
// This callback will be run once an image has actually been displayed.
ondisplayed,
}={}) =>
{
// When quick view displays an image on mousedown, we want to see the mousedown too
// now that we're displayed.
this.pointer_listener.check();
// A special case is when we have no images at all. This happens when navigating
// to a manga page and we don't have illust info yet, so we don't know anything about
// the page.
if(url == null && preview_url == null)
{
this.remove_images();
return;
}
let img = document.createElement("img");
img.src = url? url:helpers.blank_image;
img.className = "filtering";
// Create the low-res preview. This loads the thumbnail underneath the main image. Don't set the
// "filtering" class, since using point sampling for the thumbnail doesn't make sense. If preview_url
// is null, just use a blank image.
let preview_img = document.createElement("img");
preview_img.src = preview_url? preview_url:helpers.blank_image;
preview_img.classList.add("low-res-preview");
preview_img.style.pointerEvents = "none";
// Get the new image ready before removing the old one, to avoid flashing a black
// screen while the new image decodes. This will finish quickly if the preview image
// is preloaded.
//
// We have to work around an API limitation: there's no way to abort decode(). If
// a couple decode() calls from previous navigations are still running, this decode can
// be queued, even though it's a tiny image and would finish instantly. If a previous
// decode is still running, skip this and prefer to just add the image. It causes us
// to flash a blank screen when navigating quickly, but image switching is more responsive.
//
// If width and height are null, always do this so we can get the image dimensions.
if(!this.decoding)
{
try {
await preview_img.decode();
if(width == null)
{
width = preview_img.naturalWidth;
height = preview_img.naturalHeight;
}
} catch(e) {
// Ignore exceptions from aborts.
}
}
signal.check();
// Work around a Chrome quirk: even if an image is already decoded, calling img.decode()
// will always delay and allow the page to update. This means that if we add the preview
// image, decode the main image, then display the main image, the preview image will
// flicker for one frame, which is ugly. Work around this: if the image is fully downloaded,
// call decode() and see if it finishes quickly. If it does, we'll skip the preview and just
// show the final image.
let img_ready = false;
let decode_promise = null;
if(url != null && img && img.complete)
{
decode_promise = this.decode_img(img);
// See if it finishes quickly.
img_ready = await helpers.await_with_timeout(decode_promise, 50) != "timed-out";
}
signal.check();
// Remove the old image.
this.remove_images();
// Finalize our data. Don't do this until we've called this.remove_images().
this.original_width = width;
this.original_height = height;
this.img = img;
this.preview_img = preview_img;
if(restore_position == "history")
this.restore_from_history();
else if(restore_position == "auto")
this.reset_position();
this.reposition();
// Let the caller know that we've displayed an image. (We actually haven't since that
// happens just below, but this is only used to let viewer_images know that history
// has been restored.)
if(ondisplayed)
ondisplayed();
// If the load already finished, just add the main image and don't use the preview.
if(img_ready)
{
this.image_container.appendChild(this.img);
return;
}
// If the new preview was complete, removing the previous image may have been deferred.
// Wait for it to decode, and then add the new preview and remove the old one at the
// same time. This prevents flashing a blank screen for a frame while the preview
// decodes.
this.image_container.appendChild(this.preview_img);
// If we don't have a main URL, stop here. We only have the preview to display.
if(url == null)
return;
// If the image isn't downloaded, load it now. img.decode will do this too, but it
// doesn't support AbortSignal.
if(!img.complete)
{
let result = await helpers.wait_for_image_load(img, signal);
if(result != null)
return;
signal.check();
}
// Decode the image asynchronously before adding it. This is cleaner for large images,
// since Chrome blocks the UI thread when setting up images. The downside is it doesn't
// allow incremental loading.
//
// If we already have decode_promise, we already started the decode, so just wait for that
// to finish.
if(!decode_promise)
decode_promise = this.decode_img(img);
await decode_promise;
signal.check();
this.image_container.appendChild(img);
preview_img.remove();
}
async decode_img(img)
{
this.decoding = true;
try {
await img.decode();
} catch(e) {
// Ignore exceptions from aborts.
} finally {
this.decoding = false;
}
}
remove_images()
{
this.cancel_save_to_history();
// Clear the image URLs when we remove them, so any loads are cancelled. This seems to
// help Chrome with GC delays.
if(this.img)
{
this.img.remove();
this.img.src = helpers.blank_image;
this.img = null;
}
if(this.preview_img)
{
this.preview_img.remove();
this.preview_img.src = helpers.blank_image;
this.preview_img = null;
}
}
shutdown()
{
this.stop_dragging();
this.remove_images();
this.event_shutdown.abort();
this.set_new_image.abort();
this.image_container = 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();
}
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)
{
// If this is a simulated press event, the button was pressed on the previous page,
// probably due to quick view. Don't start a zoom from this press, but do allow
// locked zoom to be moved from it.
if(e.type == "simulatedpointerdown" && !this._locked_zoom)
return;
// We only want clicks on the image, or on the container backing the image, not other
// elements inside the container.
if(e.target != this.img && e.target != this.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.image_container.setPointerCapture(this.captured_pointer_id);
// If this is a click-zoom, align the zoom to the point on the image that
// was clicked.
if(!this._locked_zoom)
this.set_image_position([e.pageX, e.pageY], zoom_center_pos);
this.reposition();
// Only listen to pointermove while we're dragging.
this.image_container.addEventListener("pointermove", this.pointermove);
} else {
if(this.captured_pointer_id == null || e.pointerId != this.captured_pointer_id)
return;
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.image_container.releasePointerCapture(this.captured_pointer_id);
this.captured_pointer_id = null;
}
document.body.classList.remove("hide-ui");
this._mouse_pressed = false;
this.reposition();
}
pointermove(e)
{
if(!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})
{
// Send pointer movements to linked tabs.
SendImage.send_mouse_movement_to_linked_tabs(movementX, movementY);
// Apply mouse dragging.
let x_offset = movementX;
let y_offset = movementY;
if(settings.get("invert-scrolling"))
{
x_offset *= -1;
y_offset *= -1;
}
// This will make mouse dragging match the image exactly:
x_offset /= this.onscreen_width;
y_offset /= this.onscreen_height;
// Scale movement by the zoom factor, so we move faster if we're zoomed
// further in.
let zoom_factor = this._zoom_factor_current;
x_offset *= zoom_factor;
y_offset *= zoom_factor;
this.center_pos[0] += x_offset;
this.center_pos[1] += y_offset;
this.reposition();
}
// Return true if zooming is active.
get zoom_active()
{
return this._mouse_pressed || this._locked_zoom;
}
get _image_to_screen_ratio()
{
let screen_width = this.container_width;
let screen_height = this.container_height;
// In case we're hidden and have no width, make sure we don't return an invalid value.
if(screen_width == 0 || screen_height == 0)
return 1;
return Math.min(screen_width/this.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;
}
}
// Restore the pan and zoom state from history.
restore_from_history()
{
let args = helpers.args.location;
if(args.state.zoom == null)
{
this.reset_position();
return;
}
this.zoom_level = args.state.zoom?.zoom;
this.locked_zoom = args.state.zoom?.lock;
this.center_pos = [...args.state.zoom?.pos];
this.set_initial_image_position = true;
this.initial_image_position_aspect = null;
this.reposition();
this.set_initial_image_position = true;
}
// Save the pan and zoom state to history.
save_to_history = () =>
{
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/r111/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/r111/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/r111/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/r111/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/r111/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/r111/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/r111/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, options)
{
super(container);
this.container = container;
this.options = options || {};
this.manga_page_bar = options.manga_page_bar;
this.onkeydown = this.onkeydown.bind(this);
this.restore_history = false;
this.load = new SentinelGuard(this.load, this);
// Create a click and drag viewer for the image.
this.on_click_viewer = new on_click_viewer(this.container);
main_context_menu.get.on_click_viewer = this.on_click_viewer;
}
async load(signal, illust_id, page, { restore_history=false }={})
{
this.restore_history = restore_history;
this.illust_id = illust_id;
this._page = page;
// 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 thumbnail_data.singleton().get_or_load_illust_data(this.illust_id);
// Stop if we were removed before the request finished.
signal.check();
// Early data only gives us the image dimensions for page 1.
this.images = [{
preview_url: early_illust_data.previewUrls[0],
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.
signal.check();
// Update the list to include the image URLs.
this.images = [];
for(let manga_page of this.illust_data.mangaPages)
{
this.images.push({
url: manga_page.urls.original,
preview_url: manga_page.urls.small,
width: manga_page.width,
height: manga_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.load() is running, cancel it.
this.load.abort();
if(this.on_click_viewer)
{
this.on_click_viewer.shutdown();
this.on_click_viewer = null;
}
main_context_menu.get.on_click_viewer = null;
}
get page()
{
return this._page;
}
set page(page)
{
this._page = page;
this.refresh();
}
refresh()
{
// If we don't have this.images, load() hasn't set it up yet.
if(this.images == null)
return;
// We should always have an entry for each page.
let current_image = this.images[this._page];
if(current_image == null)
{
console.log(\`No info for page \${this._page} yet\`);
this.on_click_viewer.set_new_image();
return;
}
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({
url: current_image.url,
preview_url: current_image.preview_url,
width: current_image.width,
height: current_image.height,
restore_position: this.restore_history? "history":"auto",
ondisplayed: (e) => {
// Clear restore_history once the image is actually restored, since we
// only want to do this the first time. We don't do this immediately
// so we don't skip it if a set_new_image call is interrupted when we
// replace preview images (history has always been restored when we get
// here).
this.restore_history = false;
},
});
// If we have a manga_page_bar, update to show the current page.
if(this.manga_page_bar)
{
if(this.images.length == 1)
this.manga_page_bar.set(null);
else
this.manga_page_bar.set((this._page+1) / this.images.length);
}
}
onkeydown(e)
{
if(e.ctrlKey || e.altKey || e.metaKey)
return;
switch(e.keyCode)
{
case 36: // home
e.stopPropagation();
e.preventDefault();
main_controller.singleton.show_illust(this.illust_id, {
page: 0,
});
return;
case 35: // end
e.stopPropagation();
e.preventDefault();
main_controller.singleton.show_illust(this.illust_id, {
page: -1,
});
return;
}
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r111/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.tagList);
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/r111/src/viewer_muted.js
`;
ppixiv.resources["src/viewer_ugoira.js"] = `"use strict";
ppixiv.viewer_ugoira = class extends ppixiv.viewer
{
constructor(container, options)
{
super(container);
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.load = new SentinelGuard(this.load, 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);
}
async load(signal, illust_id, manga_page)
{
this.unload();
this.illust_id = illust_id;
// Load early data to show the low-res preview quickly. This is a simpler version of
// what viewer_images does,.
let early_illust_data = await thumbnail_data.singleton().get_or_load_illust_data(this.illust_id);
signal.check();
this.create_preview_images(early_illust_data.previewUrls[0], null);
// Load full data.
this.illust_data = await image_data.singleton().get_image_info(this.illust_id);
signal.check();
this.create_preview_images(this.illust_data.urls.small, this.illust_data.urls.original);
// This can be used to abort ZipImagePlayer's download.
this.abort_controller = new AbortController;
// 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();
}
// Undo load().
unload()
{
// Cancel the player's download.
if(this.abort_controller)
{
this.abort_controller.abort();
this.abort_controller = null;
}
// Send a finished progress callback if we were still loading.
this.progress(null);
if(this.player)
{
this.player.pause();
this.player = null;
}
if(this.preview_img1)
{
this.preview_img1.remove();
this.preview_img1 = null;
}
if(this.preview_img2)
{
this.preview_img2.remove();
this.preview_img2 = null;
}
}
// Undo load() and the constructor.
shutdown()
{
this.unload();
super.shutdown();
// If this.load() is running, cancel it.
this.load.abort();
if(this.seek_bar)
{
this.seek_bar.set_callback(null);
this.seek_bar = null;
}
window.removeEventListener("visibilitychange", this.refresh_focus);
this.canvas.remove();
}
async create_preview_images(url1, url2)
{
if(this.preview_img1)
{
this.preview_img1.remove();
this.preview_img1 = null;
}
if(this.preview_img2)
{
this.preview_img2.remove();
this.preview_img2 = null;
}
// Create an image to display the static image while we load.
//
// Like static image viewing, load the thumbnail, then the main image on top, since
// the thumbnail will often be visible immediately.
if(url1)
{
let img1 = document.createElement("img");
img1.classList.add("low-res-preview");
img1.style.position = "absolute";
img1.style.width = "100%";
img1.style.height = "100%";
img1.style.objectFit = "contain";
img1.src = url1;
this.container.appendChild(img1);
this.preview_img1 = img1;
// Allow clicking the previews too, so if you click to pause the video before it has enough
// data to start playing, it'll still toggle to paused.
img1.addEventListener("click", this.clicked_canvas, false);
}
if(url2)
{
let img2 = document.createElement("img");
img2.style.position = "absolute";
img2.className = "filtering";
img2.style.width = "100%";
img2.style.height = "100%";
img2.style.objectFit = "contain";
img2.src = url2;
this.container.appendChild(img2);
img2.addEventListener("click", this.clicked_canvas, false);
this.preview_img2 = img2;
// Wait for the high-res image to finish loading.
let img1 = this.preview_img1;
helpers.wait_for_image_load(img2).then(() => {
// Remove the low-res preview image when the high-res one finishes loading.
img1.remove();
});
}
}
set active(active)
{
super.active = active;
// Rewind the video when we're not visible.
if(!active && this.player != null)
this.player.rewind();
// Refresh playback, since we pause while the viewer isn't visible.
this.refresh_focus();
}
progress(value)
{
if(this.options.progress_bar)
this.options.progress_bar.set(value);
}
// 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();
}
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/r111/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/r111/src/zip_image_player.js
`;
ppixiv.resources["src/screen.js"] = `"use strict";
// The base class for our main screens.
ppixiv.screen = class extends ppixiv.widget
{
constructor(options)
{
super(options);
// 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/r111/src/screen.js
`;
ppixiv.resources["src/screen_illust.js"] = `"use strict";
// The main UI. This handles creating the viewers and the global UI.
ppixiv.screen_illust = class extends ppixiv.screen
{
constructor(options)
{
super(options);
this.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.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, {parent: this, progress_bar: 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) => {
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;
// If remote quick view is active, send this image. Only do this if we have
// focus, since if we don't have focus, we're probably receiving this from another
// tab.
if(settings.get("linked_tabs_enabled"))
SendImage.send_image(illust_id, manga_page, settings.get("linked_tabs", []), "temp-view");
// Get very basic illust info. This is enough to tell which viewer to use, how
// many pages it has, and whether it's muted. This will always complete immediately
// if we're coming from a search or anywhere else that will already have this info,
// but it can block if we're loading from scratch.
let early_illust_data = await thumbnail_data.singleton().get_or_load_illust_data(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();
// Create the image viewer.
let viewer_class;
if(early_illust_data.illustType == 2)
viewer_class = viewer_ugoira;
else
viewer_class = viewer_images;
// If we already have a viewer, only recreate it if we need a different type.
// Reusing the same viewer when switching images helps prevent flicker.
if(this.viewer && !(this.viewer instanceof viewer_class))
this.remove_viewer();
if(this.viewer == null)
{
let image_container = this.container.querySelector(".image-container");
this.viewer = new viewer_class(image_container, {
progress_bar: this.progress_bar.controller(),
manga_page_bar: this.manga_page_bar,
seek_bar: this.seek_bar,
});
}
this.viewer.load(illust_id, 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.tagList);
let muted_user = muting.singleton.is_muted_user_id(early_illust_data.userId);
if(this.view_muted || (!muted_tag && !muted_user))
return { is_muted: false };
return { is_muted: true, muted_tag: muted_tag, muted_user: muted_user };
}
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();
// Tell the preloader that we're not displaying an image anymore.
image_preloader.singleton.set_current_image(null, null);
image_preloader.singleton.set_speculative_image(null, null);
// If remote quick view is active, cancel it if we leave the image.
SendImage.send_message({
message: "send-image",
action: "cancel",
to: settings.get("linked_tabs", []),
});
}
data_source_updated()
{
this.refresh_ui();
}
get active()
{
return this._active;
}
// Refresh the UI for the current image.
refresh_ui()
{
// Don't refresh if the thumbnail view is active. We're not visible, and we'll just
// step over its page title, etc.
if(!this._active)
return;
// Tell the UI which page is being viewed.
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 thumbnail_data.singleton().get_or_load_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;
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");
}
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 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 };
}
// If this.pending_navigation is no longer the same as pending_navigation, we navigated since
// we requested this load and this navigation is stale, so stop.
if(this.pending_navigation != pending_navigation)
{
console.error("Aborting stale navigation");
return { stale: true };
}
this.pending_navigation = null;
// 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/r111/src/screen_illust.js
`;
ppixiv.resources["src/screen_search.js"] = `"use strict";
// The search UI.
ppixiv.screen_search = class extends ppixiv.screen
{
constructor(options)
{
super(options);
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_container = this.container.querySelector(".avatar-container");
this.avatar_widget = new avatar_widget({
container: this.avatar_container,
changed_callback: this.data_source_updated,
big: true,
mode: "dropdown",
});
// 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;
await image_data.singleton().get_image_info(a.dataset.illustId);
}, true);
this.container.querySelector(".refresh-search-button").addEventListener("click", this.refresh_search.bind(this));
this.container.querySelector(".whats-new-button").addEventListener("click", this.whats_new.bind(this));
this.container.querySelector(".thumbnails").addEventListener("click", this.thumbnail_onclick);
// Handle quick_view.
new ppixiv.pointer_listener({
element: this.container.querySelector(".thumbnails"),
button_mask: 0b1,
callback: (e) => {
let a = e.target.closest("A");
if(a == null)
return;
if(!settings.get("quick_view"))
return;
// Activating on press would probably break navigation on touchpads, so only do
// this for mouse events.
if(e.pointerType != "mouse")
return;
let { illust_id, page } = main_controller.singleton.get_illust_at_element(e.target);
if(illust_id == null)
return;
// Don't stopPropagation. We want the illustration view to see the press too.
e.preventDefault();
// e.stopImmediatePropagation();
main_controller.singleton.show_illust(illust_id, {
page: page,
add_to_history: true,
});
},
});
// Clear recent illusts:
this.container.querySelector("[data-type='clear-recents']").addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
await ppixiv.recently_seen_illusts.get().clear();
this.refresh_search();
});
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_container.hidden = true;
// 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.
let url = info.previewUrls[0];
var thumb = element.querySelector(".thumb");
// Check if this illustration is muted (blocked).
var muted_tag = muting.singleton.any_tag_muted(info.tagList);
var muted_user = muting.singleton.is_muted_user_id(info.userId);
if(muted_tag || muted_user)
{
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 = thumbnail_data.singleton().get_profile_picture_url(info.userId);
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/r111/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(options)
{
super(options);
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"), { parent: this, progress_bar: 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)
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.ui.visible = 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(this.illust_info.tagList);
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;
}
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)
{
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;
// 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/r111/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 extends ppixiv.widget
{
constructor(container, {progress_bar, ...options})
{
super({container, ...options});
this.clicked_download = this.clicked_download.bind(this);
this.refresh = this.refresh.bind(this);
this.container = container;
this.progress_bar = progress_bar;
this._visible = false;
this.ui = helpers.create_from_template(".template-image-ui");
this.container.appendChild(this.ui);
this.avatar_widget = new avatar_widget({
container: 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({
parent: this,
container: this.container.querySelector(".popup-bookmark-tag-dropdown-container"),
});
this.toggle_tag_widget = new toggle_bookmark_tag_list_widget({
parent: this,
container: this.container.querySelector(".button-bookmark-tags"),
bookmark_tag_widget: this.bookmark_tag_widget,
});
this.like_button = new like_button_widget({
parent: this,
container: this.container.querySelector(".button-like"),
});
this.like_count_widget = new like_count_widget({
parent: this,
container: this.container.querySelector(".button-like .count"),
});
this.bookmark_count_widget = new bookmark_count_widget({
parent: this,
container: this.container.querySelector(".button-bookmark .count"),
});
// 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({
parent: this,
container: a,
private_bookmark: a.classList.contains("private"),
bookmark_tag_widget: this.bookmark_tag_widget,
}));
for(let button of this.container.querySelectorAll(".download-button"))
button.addEventListener("click", this.clicked_download);
this.container.querySelector(".download-manga-button").addEventListener("click", this.clicked_download);
this.container.querySelector(".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)
{
if(this._visible == value)
return;
this._visible = value;
this.avatar_widget.visible = value;
if(value)
this.refresh();
}
set data_source(data_source)
{
if(this._data_source == data_source)
return;
this._data_source = data_source;
this.refresh();
}
shutdown()
{
image_data.singleton().illust_modified_callbacks.unregister(this.refresh);
this.avatar_widget.shutdown();
}
get 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.refresh();
}
handle_onkeydown(e)
{
}
async refresh()
{
// Don't do anything if we're not visible.
if(!this._visible)
return;
// Update widget illust IDs.
this.like_button.illust_id = this._illust_id;
this.bookmark_tag_widget.illust_id = this._illust_id;
this.toggle_tag_widget.illust_id = this._illust_id;
this.like_count_widget.illust_id = this._illust_id;
this.bookmark_count_widget.illust_id = this._illust_id;
for(let button of this.bookmark_buttons)
button.illust_id = this._illust_id;
this.illust_data = null;
if(this._illust_id == null)
return;
// We need image info to update.
let illust_id = this._illust_id;
let illust_info = await image_data.singleton().get_image_info(illust_id);
// Check if anything changed while we were loading.
if(illust_info == null || illust_id != this._illust_id || !this._visible)
return;
this.illust_data = illust_info;
let user_id = illust_info.userId;
// Show the author if it's someone else's post, or the edit link if it's ours.
var our_post = global_data.user_id == user_id;
this.container.querySelector(".author-block").hidden = our_post;
this.container.querySelector(".edit-post").hidden = !our_post;
this.container.querySelector(".edit-post").href = "/member_illust_mod.php?mode=mod&illust_id=" + illust_id;
this.avatar_widget.set_user_id(user_id);
this.tag_widget.set(illust_info.tagList);
var element_title = this.container.querySelector(".title");
element_title.textContent = illust_info.illustTitle;
element_title.href = "/artworks/" + illust_id + "#ppixiv";
var element_author = this.container.querySelector(".author");
element_author.textContent = illust_info.userName;
element_author.href = \`/users/\${user_id}#ppixiv\`;
this.container.querySelector(".similar-illusts-button").href = "/bookmark_detail.php?illust_id=" + illust_id + "#ppixiv?recommendations=1";
this.container.querySelector(".similar-artists-button").href = "/discovery/users#ppixiv?user_id=" + user_id;
this.container.querySelector(".similar-bookmarks-button").href = "/bookmark_detail.php?illust_id=" + illust_id + "#ppixiv";
// Fill in the post info text.
this.set_post_info(this.container.querySelector(".post-info"));
// The comment (description) can contain HTML.
var element_comment = this.container.querySelector(".description");
element_comment.hidden = illust_info.illustComment == "";
element_comment.innerHTML = illust_info.illustComment;
helpers.fix_pixiv_links(element_comment);
helpers.make_pixiv_links_internal(element_comment);
// Set the download button popup text.
let download_image_button = this.container.querySelector(".download-image-button");
download_image_button.hidden = !actions.is_download_type_available("image", illust_info);
let download_manga_button = this.container.querySelector(".download-manga-button");
download_manga_button.hidden = !actions.is_download_type_available("ZIP", illust_info);
let download_video_button = this.container.querySelector(".download-video-button");
download_video_button.hidden = !actions.is_download_type_available("MKV", illust_info);
// Set 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/r111/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/r111/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/r111/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/r111/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 user's profile picture URL, or a fallback if we haven't seen it.
get_profile_picture_url(user_id)
{
let result = this.user_profile_urls[user_id];
if(!result)
result = "https://s.pximg.net/common/images/no_profile.png";
return result;
}
// Get the mapping from /ajax/user/id/illusts/bookmarks to illust_list.php's keys.
get thumbnail_info_map_illust_list()
{
if(this._thumbnail_info_map_illust_list != null)
return this._thumbnail_info_map_illust_list;
this._thumbnail_info_map_illust_list = [
["illust_id", "id"],
["url", "url"],
["tags", "tags"],
["illust_user_id", "userId"],
["illust_width", "width"],
["illust_height", "height"],
["illust_type", "illustType"],
["illust_page_count", "pageCount"],
["illust_title", "illustTitle"],
["user_profile_img", "profileImageUrl"],
["user_name", "userName"],
// illust_list.php doesn't give the creation date.
[null, "createDate"],
];
return this._thumbnail_info_map_illust_list;
};
get thumbnail_info_map_ranking()
{
if(this._thumbnail_info_map_ranking != null)
return this._thumbnail_info_map_ranking;
this._thumbnail_info_map_ranking = [
["illust_id", "id"],
["url", "url"],
["tags", "tags"],
["user_id", "userId"],
["width", "width"],
["height", "height"],
["illust_type", "illustType"],
["illust_page_count", "pageCount"],
["title", "illustTitle"],
["profile_img", "profileImageUrl"],
["user_name", "userName"],
["illust_upload_timestamp", "createDate"],
];
return this._thumbnail_info_map_ranking;
};
// This is called when we have new thumbnail data available. thumb_result is
// an array of thumbnail items.
//
// This can come from a bunch of different places, which all return the same data, but
// each in a different way:
//
// name URL
// normal /ajax/user/id/illusts/bookmarks
// illust_list illust_list.php
// following bookmark_new_illust.php
// following search.php
// rankings ranking.php
//
// We map each of these to "normal".
//
// These have the same data, but for some reason everything has different names.
// Remap them to "normal", and check that all fields we expect exist, to make it
// easier to notice if something is wrong.
loaded_thumbnail_info(thumb_result, source)
{
if(thumb_result.error)
return;
let remapped_thumb_info = null;
for(var thumb_info of thumb_result)
{
// Ignore entries with "isAdContainer". These aren't search results at all and just contain
// stuff we're not interested in.
if(thumb_info.isAdContainer)
continue;
if(source == "normal")
{
// The data is already in the format we want. The only change we make is
// to rename title to illustTitle, to match it up with illust info.
if(!("title" in thumb_info))
{
console.warn("Thumbnail info is missing key: title");
}
else
{
thumb_info.illustTitle = thumb_info.title;
delete thumb_info.title;
}
// Check that all keys we expect exist, and remove any keys we don't know about
// so we don't use them accidentally.
let thumbnail_info_map = this.thumbnail_info_map_ranking;
remapped_thumb_info = { };
for(let pair of thumbnail_info_map)
{
let key = pair[1];
if(!(key in thumb_info))
{
console.warn("Thumbnail info is missing key:", key);
continue;
}
remapped_thumb_info[key] = thumb_info[key];
}
if(!('bookmarkData' in thumb_info))
console.warn("Thumbnail info is missing key: bookmarkData");
else
{
remapped_thumb_info.bookmarkData = thumb_info.bookmarkData;
// See above.
if(remapped_thumb_info.bookmarkData != null)
delete remapped_thumb_info.bookmarkData.bookmarkId;
}
}
else if(source == "illust_list" || source == "rankings")
{
// Get the mapping for this mode.
let thumbnail_info_map =
source == "illust_list"? this.thumbnail_info_map_illust_list:
this.thumbnail_info_map_ranking;
remapped_thumb_info = { };
for(let pair of thumbnail_info_map)
{
let from_key = pair[0];
let to_key = pair[1];
if(from_key == null)
{
// This is just for illust_list createDate.
remapped_thumb_info[to_key] = null;
continue;
}
if(!(from_key in thumb_info))
{
console.warn("Thumbnail info is missing key:", from_key);
continue;
}
let value = thumb_info[from_key];
remapped_thumb_info[to_key] = value;
}
// Make sure that the illust IDs and user IDs are strings.
remapped_thumb_info.id = "" + remapped_thumb_info.id;
remapped_thumb_info.userId = "" + remapped_thumb_info.userId;
// Bookmark data is a special case.
//
// The old API has is_bookmarked: true, bookmark_id: "id" and bookmark_illust_restrict: 0 or 1.
// bookmark_id and bookmark_illust_restrict are omitted if is_bookmarked is false.
//
// The new API is a dictionary:
//
// bookmarkData = {
// bookmarkId: id,
// private: false
// }
//
// or null if not bookmarked.
//
// A couple sources of thumbnail data (bookmark_new_illust.php and search.php)
// don't return the bookmark ID. We don't use this (we only edit bookmarks from
// the image page, where we have full image data), so we omit bookmarkId from this
// data.
//
// Some pages return buggy results. /ajax/user/id/profile/all includes bookmarkData,
// but private is always false, so we can't tell if it's a private bookmark. This is
// a site bug that we can't do anything about (it affects the site too).
remapped_thumb_info.bookmarkData = null;
if(!('is_bookmarked' in thumb_info))
console.warn("Thumbnail info is missing key: is_bookmarked");
if(thumb_info.is_bookmarked)
{
remapped_thumb_info.bookmarkData = {
// See above.
// bookmarkId: thumb_info.bookmark_id,
private: thumb_info.bookmark_illust_restrict == 1,
};
}
// illustType can be a string in these instead of an int, so convert it.
remapped_thumb_info.illustType = parseInt(remapped_thumb_info.illustType);
if(source == "rankings")
{
// Rankings thumbnail info gives createDate as a Unix timestamp. Convert
// it to the same format as everything else.
let date = new Date(remapped_thumb_info.createDate*1000);
remapped_thumb_info.createDate = date.toISOString();
}
else if(source == "illust_list")
{
// This is the only source of thumbnail data that doesn't give createDate at
// all. This source is very rarely used now, so just fill in a bogus date.
remapped_thumb_info.createDate = new Date(0).toISOString();
}
}
else if(source == "internal")
{
remapped_thumb_info = thumb_info;
}
else
throw "Unrecognized source: " + source;
// "internal" is for thumbnail data which is already processed.
if(source != "internal")
{
// These fields are strings in some sources. Switch them to ints.
for(let key of ["pageCount", "width", "height"])
{
if(remapped_thumb_info[key] != null)
remapped_thumb_info[key] = parseInt(remapped_thumb_info[key]);
}
// Different APIs return different thumbnail URLs.
remapped_thumb_info.url = helpers.get_high_res_thumbnail_url(remapped_thumb_info.url);
// Create a list of thumbnail URLs.
remapped_thumb_info.previewUrls = [];
for(let page = 0; page < remapped_thumb_info.pageCount; ++page)
{
let url = helpers.get_high_res_thumbnail_url(remapped_thumb_info.url, page);
remapped_thumb_info.previewUrls.push(url);
}
// Remove url. Use previewUrl[0] instead
delete remapped_thumb_info.url;
// Rename .tags to .tagList, for consistency with the flat tag list in illust info.
remapped_thumb_info.tagList = remapped_thumb_info.tags;
delete remapped_thumb_info.tags;
}
thumb_info = remapped_thumb_info;
// Store the data.
this.add_thumbnail_info(thumb_info);
var illust_id = thumb_info.id;
delete this.loading_ids[illust_id];
// This is really annoying: the profile picture is the only field that's present in thumbnail
// info but not illust info. We want a single basic data set for both, so that can't include
// the profile picture. But, we do want to display it in places where we can't get user
// info (muted search results), so store it separately.
if(thumb_info.profileImageUrl)
{
let profile_image_url = thumb_info.profileImageUrl;
profile_image_url = profile_image_url.replace("_50.", "_170."),
this.user_profile_urls[thumb_info.userId] = profile_image_url;
delete thumb_info.profileImageUrl;
}
}
// Broadcast that we have new thumbnail data available.
window.dispatchEvent(new Event("thumbnailsLoaded"));
};
// Store thumbnail info.
add_thumbnail_info(thumb_info)
{
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];
}
thumbnail_info_keys = [
"id",
"illustType",
"illustTitle",
"pageCount",
"userId",
"userName",
"width",
"height",
"previewUrls",
"bookmarkData",
"createDate",
"tagList",
];
// Return thumbnail data for a single illust if it's available. If it isn't, read
// full illust info, and then return thumbnail data.
//
// This is used when we're displaying info for a single image, and the caller only
// needs thumbnail data. It allows us to use either thumbnail data or illust info,
// so we can usually return the data immediately.
//
// If it isn't available and we need to load it, we load illust info instead of thumbnail
// data, since it takes a full API request either way.
//
// If load is false, return null if we have no data instead of loading it.
async get_or_load_illust_data(illust_id, load=true)
{
let data = thumbnail_data.singleton().get_one_thumbnail_info(illust_id);
if(data == null)
{
if(load)
data = await image_data.singleton().get_image_info(illust_id);
else
data = image_data.singleton().get_image_info_sync(illust_id);
if(data == null)
return null;
}
// Verify whichever data type we got.
for(let key of this.thumbnail_info_keys)
{
if(!(key in data))
{
console.warn(\`Missing key \${key} for early data\`, data);
continue;
}
}
return data;
}
// Update illustration data in both thumbnail info and illust info.
//
// This is used in places that use get_or_load_illust_data to get thumbnail
// info, and then need to save changes to it. Update both sources.
//
// This can't update tags.
update_illust_data(illust_id, data)
{
let update_data = (update, keys) => {
for(let key of keys)
{
if(!(key in data))
continue;
console.assert(key != "tags");
update[key] = data[key];
}
};
let thumb_data = thumbnail_data.singleton().get_one_thumbnail_info(illust_id);
if(thumb_data)
update_data(thumb_data, this.thumbnail_info_keys);
let illust_info = image_data.singleton().get_image_info_sync(illust_id);
if(illust_info != null)
update_data(illust_info, this.thumbnail_info_keys);
image_data.singleton().call_illust_modified_callbacks(illust_id);
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r111/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/r111/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/r111/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/r111/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, onerror=null)
{
super();
this.url = url;
this.onerror = onerror;
}
// Start the fetch. This should only be called once.
async start()
{
let img = document.createElement("img");
img.src = this.url;
let result = await helpers.wait_for_image_load(img, this.abort_controller.signal);
if(result == "failed" && this.onerror)
this.onerror();
}
}
// Load a resource with fetch.
class fetch_preloader extends preloader
{
constructor(url)
{
super();
this.url = url;
}
async start()
{
let request = helpers.send_pixiv_request({
url: this.url,
method: "GET",
signal: this.abort_controller.signal,
});
// Wait for the body to download before completing. Ignore errors here (they'll
// usually be cancellations).
try {
request = await request;
await request.text();
} catch(e) { }
}
}
// The image preloader singleton.
ppixiv.image_preloader = class
{
// Return the singleton, creating it if needed.
static get singleton()
{
if(image_preloader._singleton == null)
image_preloader._singleton = new image_preloader();
return image_preloader._singleton;
};
constructor()
{
// The _preloader objects that we're currently running.
this.preloads = [];
// A queue of URLs that we've finished preloading recently. We use this to tell if
// we don't need to run a preload.
this.recently_preloaded_urls = [];
}
// Set the 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;
await this.guess_preload(illust_id, page);
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;
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;
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.tagList))
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 url of illust_data.previewUrls)
results.push(new img_preloader(url));
// Preload the requested page.
if(page < illust_data.mangaPages.length)
results.push(new img_preloader(illust_data.mangaPages[page].urls.original));
return results;
}
// Try to start a guessed preload.
//
// This uses guess_image_url to try to figure out the image URL earlier. Normally
// we have to wait for the image info request to finish before we have the image URL
// to start loading, but if we can guess the URL correctly then we can start loading
// it immediately.
//
// If illust_id is null, stop any running guessed preload.
async guess_preload(illust_id, page)
{
// See if we can guess the image's URL from previous info, or if we can figure it
// out from another source.
let guessed_url = null;
if(illust_id != null && page != null)
{
guessed_url = await guess_image_url.get.guess_url(illust_id, page);
if(this.guessed_preload && this.guessed_preload.url == guessed_url)
return;
}
// Cancel any previous guessed preload.
if(this.guessed_preload)
{
this.guessed_preload.cancel();
this.guessed_preload = null;
}
// Start the new guessed preload.
if(guessed_url)
{
this.guessed_preload = new img_preloader(guessed_url, () => {
// The image load failed. Let guessed_preload know.
console.info("Guessed image load failed");
guess_image_url.get.guessed_url_incorrect(illust_id, page);
});
this.guessed_preload.start();
}
}
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r111/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: 110,
text: \`
Added Quick View. This views images immediately when the mouse is pressed,
and images can be panned with the same press.
This can be enabled in preferences, and may become the default in a future release.
\`
},
{
version: 109,
boring: true,
text: \`Added a visual marker on thumbnails to show the last image you viewed.\`
},
{
version: 104,
text:
"Bookmarks can now be shuffled, to view them in random order. " +
"
" +
"Bookmarking an image now always likes it, like Pixiv's mobile app. " +
"(Having an option for this didn't seem useful.)" +
"
" +
"Added a Recent History search, to show recent search results. This can be turned " +
"off in settings."
},
{
version: 102,
boring: true,
text:
"Animations now start playing much faster."
},
{
version: 100,
text:
"Enabled auto-liking images on bookmark by default, to match the Pixiv mobile apps. " +
"If you've previously changed this in preferences, your setting should stay the same." +
"
" +
"Added a download button for the current page when viewing manga posts."
},
{
version: 97,
text:
"Holding Ctrl now displays the popup menu, to make it easier to use for people on touchpads.
" +
"
" +
"Keyboard hotkeys reworked, and can now be used while hovering over search results.
" +
"
" +
"Ctrl-V - like image\\n" +
"Ctrl-B - bookmark\\n" +
"Ctrl-Alt-B - bookmark privately\\n" +
"Ctrl-Shift-B - remove bookmark\\n" +
"Ctrl-Alt-Shift-M - add bookmark tag\\n" +
"Ctrl-F - follow\\n" +
"Ctrl-Alt-F - follow privately\\n" +
"Ctrl-Shift-F - unfollow\\n" +
"
"
},
{
version: 89,
text:
"Reworked zooming to make it more consistent and easier to use." +
"
" +
"You can now zoom images to 100% to view them at actual size."
},
{
version: 82,
text:
"Press Ctrl-Alt-Shift-B to bookmark an image with a new tag."
},
{
version: 79,
text:
"Added support for viewing new R-18 works by followed users."
},
{
version: 77,
text:
"Added user searching." +
"
" +
"Commercial/subscription links in user profiles (Fanbox, etc.) now use a different icon."
},
{
version: 74,
text:
"Viewing your followed users by tag is now supported." +
"
" +
"You can now view other people who bookmarked an image, to see what else they've bookmarked. " +
"This is available from the top-left hover menu."
},
{
version: 72,
text:
"The followed users page now remembers which page you were on if you reload the page, to make " +
"it easier to browse your follows if you have a lot of them." +
"
" +
"Returning to followed users now flashes who you were viewing like illustrations do," +
"to make it easier to pick up where you left off." +
"
" +
"Added a browser back button to the context menu, to make navigation easier in fullscreen " +
"when the browser back button isn't available."
},
{
version: 68,
text:
"You can now go to either the first manga page or the page list from search results. " +
"Click the image to go to the first page, or the page count to go to the page list." +
"
" +
"Our button is now in the bottom-left when we're disabled, since Pixiv now puts a menu " +
"button in the top-left and we were covering it up."
},
{
version: 65,
text:
"Bookmark viewing now remembers which page you were on if the page is reloaded." +
"
"+
"Zooming is now in smaller increments, to make it easier to zoom to the level you want."
},
{
version: 57,
text:
"Search for similar artists. Click the recommendations item at the top of the artist page, " +
"or in the top-left when viewing an image." +
"
"+
"You can also now view suggested artists."
},
{
version: 56,
text:
"Tag translations are now supported. This can be turned off in preferences. " +
"
" +
"Added quick tag search editing. After searching for a tag, click the edit button " +
"to quickly add and remove tags."
},
{
version: 55,
text:
"The \\"original\\" view is now available in Rankings." +
"
" +
"Hiding the mouse cursor can now be disabled in preferences.",
},
{
version: 49,
text:
"Add \\"Hover to show UI\\" preference, which is useful for low-res monitors."
},
{
version: 47,
text:
"You can now view the users you're following with \\"Followed Users\\". This shows each " +
"user's most recent post."
},
];
ppixiv.whats_new = class
{
// 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/r111/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);
this.pending_movement = [0, 0];
this.listeners = {};
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);
// If we gain focus while quick view is active, finalize the image. Virtual
// history isn't meant to be left enabled, since it doesn't interact with browser
// history.
window.addEventListener("focus", (e) => {
let args = ppixiv.helpers.args.location;
if(args.hash.has("temp-view"))
{
console.log("Finalizing quick view image because we gained focus");
args.hash.delete("virtual");
args.hash.delete("temp-view");
ppixiv.helpers.set_page_url(args, false, "navigation");
}
});
SendImage.send_image_channel.addEventListener("message", this.received_message.bind(this));
this.broadcast_tab_info();
this.query_tabs();
}
static add_message_listener(message, func)
{
if(!this.listeners[message])
this.listeners[message] = [];
this.listeners[message].push(func);
}
// If we're sending an image and the page is unloaded, try to cancel it. This is
// only registered when we're sending an image.
static window_onunload(e)
{
// Tell other tabs that this tab has closed.
SendImage.send_message({ message: "tab-closed" });
}
static query_tabs()
{
SendImage.send_message({ message: "list-tabs" });
}
// Send an image to another tab. action is either "temp-view", to show the image temporarily,
// or "display", to navigate to it.
static async send_image(illust_id, page, tab_ids, action)
{
// Send everything we know about the image, so the receiver doesn't have to
// do a lookup.
let thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(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_ids,
illust_id: illust_id,
page: page,
action: action, // "temp-view" or "display"
thumbnail_info: thumbnail_info,
illust_data: illust_data,
user_info: user_info,
}, false);
}
static received_message(e)
{
let data = e.data;
// If this message has a target and it's not us, ignore it.
if(data.to && data.to.indexOf(SendImage.tab_id) == -1)
return;
// Call any listeners for this message.
if(this.listeners[data.message])
{
for(let func of this.listeners[data.message])
func(data);
}
if(data.message == "tab-info")
{
// Info about a new tab, or a change in visibility.
//
// This may contain thumbnail and illust info. We don't register it here. It
// can be used explicitly when we're displaying a tab thumbnail, but each tab
// might have newer or older image info, and propagating them back and forth
// could be confusing.
if(data.from == SendImage.tab_id)
{
// The other tab has the same ID we do. The only way this normally happens
// is if a tab is duplicated, which will duplicate its sessionStorage with it.
// If this happens, use tab_id_tiebreaker to decide who wins. The tab with
// the higher value will recreate its tab ID. This is set to the time when
// we're loaded, so this will usually cause new tabs to be the one to create
// a new ID.
if(SendImage.tab_id_tiebreaker >= data.tab_id_tiebreaker)
{
console.log("Creating a new tab ID due to ID conflict");
SendImage.tab_id = SendImage.create_tab_id(true /* recreate */ );
}
else
console.log("Tab ID conflict (other tab will create a new ID)");
// Broadcast info. If we recreated our ID then we want to broadcast it on the
// new ID. If we didn't, we still want to broadcast it to replace the info
// the other tab just sent on our ID.
this.broadcast_tab_info();
}
this.known_tabs[data.from] = data;
}
else if(data.message == "tab-closed")
{
delete this.known_tabs[data.from];
}
else if(data.message == "list-tabs")
{
// A new tab is populating its tab list.
this.broadcast_tab_info();
}
else if(data.message == "send-image")
{
// If this message has illust info or thumbnail info, register it.
let thumbnail_info = data.thumbnail_info;
if(thumbnail_info != null)
thumbnail_data.singleton().loaded_thumbnail_info([thumbnail_info], "internal");
let user_info = data.user_info;
if(user_info != null)
image_data.singleton().add_user_data(user_info);
let illust_data = data.illust_data;
if(illust_data != null)
image_data.singleton().add_illust_data(illust_data);
// To finalize, just remove preview and quick-view from the URL to turn the current
// preview into a real navigation. This is slightly different from sending "display"
// with the illust ID, since it handles navigation during quick view.
if(data.action == "finalize")
{
let args = ppixiv.helpers.args.location;
args.hash.delete("virtual");
args.hash.delete("temp-view");
ppixiv.helpers.set_page_url(args, false, "navigation");
return;
}
if(data.action == "cancel")
{
this.hide_preview_image();
return;
}
// Otherwise, we're displaying an image. quick-view displays in quick-view+virtual
// mode, display just navigates to the image normally.
console.assert(data.action == "temp-view" || data.action == "display", data.actionj);
// Show the image.
main_controller.singleton.show_illust(data.illust_id, {
page: data.page,
temp_view: data.action == "temp-view",
source: "temp-view",
// When we first show a preview, add it to history. If we show another image
// or finalize the previewed image while we're showing a preview, replace the
// preview history entry.
add_to_history: !ppixiv.history.virtual,
});
}
else if(data.message == "preview-mouse-movement")
{
// Ignore this message if we're not displaying a quick view image.
if(!ppixiv.history.virtual)
return;
// The mouse moved in the tab that's sending quick view. Broadcast an event
// like pointermove.
let event = new PointerEvent("quickviewpointermove", {
movementX: data.x,
movementY: data.y,
});
window.dispatchEvent(event);
}
}
static broadcast_tab_info()
{
let screen = main_controller.singleton.displayed_screen;
let 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,
};
this.send_message(our_tab_info);
// Add us to our own known_tabs.
this.known_tabs[SendImage.tab_id] = our_tab_info;
}
static send_message(data, send_to_self)
{
// Include the tab ID in all messages.
data.from = this.tab_id;
this.send_image_channel.postMessage(data);
if(send_to_self)
{
// Make a copy of data, so we don't modify the caller's copy.
data = JSON.parse(JSON.stringify(data));
// Set self to true to let us know that this is our own message.
data.self = true;
this.send_image_channel.dispatchEvent(new MessageEvent("message", { data: data }));
}
}
// If we're currently showing a preview image sent from another tab, back out to
// where we were before.
static hide_preview_image()
{
let was_in_preview = ppixiv.history.virtual;
if(!was_in_preview)
return;
ppixiv.history.back();
}
static send_mouse_movement_to_linked_tabs(x, y)
{
let tab_ids = settings.get("linked_tabs", []);
if(tab_ids.length == 0)
return;
this.pending_movement[0] += x;
this.pending_movement[1] += y;
// Limit the rate we send these, since mice with high report rates can send updates
// fast enough to saturate BroadcastChannel and cause messages to back up. Add up
// movement if we're sending too quickly and batch it into the next message.
if(this.last_movement_message_time != null && Date.now() - this.last_movement_message_time < 10)
return;
this.last_movement_message_time = Date.now();
SendImage.send_message({
message: "preview-mouse-movement",
x: this.pending_movement[0],
y: this.pending_movement[1],
to: tab_ids,
}, false);
this.pending_movement = [0, 0];
}
};
ppixiv.link_tabs_popup = class extends ppixiv.dialog_widget
{
constructor({...options})
{
super({template: "template-link-tabs", ...options});
this.container.querySelector(".close-button").addEventListener("click", (e) => {
this.visible = false;
});
// Close if the container is clicked, but not if something inside the container is clicked.
this.container.addEventListener("click", (e) => {
if(e.target != this.container)
return;
this.visible = false;
});
new menu_option_toggle(this.container.querySelector(".toggle-enabled"), {
label: "Enabled",
setting: "linked_tabs_enabled",
});
// Refresh the "unlink all tabs" button when the linked tab list changes.
settings.register_change_callback("linked_tabs", this.refresh_unlink_all);
this.refresh_unlink_all();
this.container.querySelector(".unlink-all").addEventListener("click", (e) => {
settings.set("linked_tabs", []);
this.send_link_tab_message();
});
// The other tab will send these messages when the link and unlink buttons
// are clicked.
SendImage.add_message_listener("link-this-tab", (message) => {
let tab_ids = settings.get("linked_tabs", []);
if(tab_ids.indexOf(message.from) == -1)
tab_ids.push(message.from);
settings.set("linked_tabs", tab_ids);
this.send_link_tab_message();
});
SendImage.add_message_listener("unlink-this-tab", (message) => {
let tab_ids = settings.get("linked_tabs", []);
let idx = tab_ids.indexOf(message.from);
if(idx != -1)
tab_ids.splice(idx, 1);
settings.set("linked_tabs", tab_ids);
this.send_link_tab_message();
});
this.visible = false;
}
refresh_unlink_all = () =>
{
let any_tabs_linked = settings.get("linked_tabs", []).length > 0;
this.container.querySelector(".unlink-all").hidden = !any_tabs_linked;
}
send_link_tab_message = () =>
{
// We should always be visible when this is called.
console.assert(this.visible);
SendImage.send_message({
message: "show-link-tab",
linked_tabs: settings.get("linked_tabs", []),
});
}
visibility_changed()
{
if(!this.visible)
{
SendImage.send_message({ message: "hide-link-tab" });
return;
}
helpers.interval(this.send_link_tab_message, 1000, this.visibility_abort.signal);
}
}
ppixiv.link_this_tab_popup = class extends ppixiv.dialog_widget
{
constructor({...options})
{
super({template: "template-link-this-tab", ...options});
this.hide_timer = new helpers.timer(() => { this.visible = false; });
// Show ourself when we see a show-link-tab message and hide if we see a
// hide-link-tab-message.
SendImage.add_message_listener("show-link-tab", (message) => {
this.other_tab_id = message.from;
this.hide_timer.set(2000);
let linked = message.linked_tabs.indexOf(SendImage.tab_id) != -1;
this.container.querySelector(".link-this-tab").hidden = linked;
this.container.querySelector(".unlink-this-tab").hidden = !linked;
this.visible = true;
});
SendImage.add_message_listener("hide-link-tab", (message) => {
this.hide_timer.clear();
this.visible = false;
});
// When "link this tab" is clicked, send a link-this-tab message.
this.container.querySelector(".link-this-tab").addEventListener("click", (e) => {
SendImage.send_message({ message: "link-this-tab", to: [this.other_tab_id] });
// If we're linked to another tab, clear our linked tab list, to try to make
// sure we don't have weird chains of tabs linking each other.
settings.set("linked_tabs", []);
});
this.container.querySelector(".unlink-this-tab").addEventListener("click", (e) => {
SendImage.send_message({ message: "unlink-this-tab", to: [this.other_tab_id] });
});
this.visible = false;
}
visibility_changed()
{
this.hide_timer.clear();
// Hide if we don't see a show-link-tab message for a few seconds, as a
// safety in case the other tab dies.
if(this.visible)
this.hide_timer.set(2000);
}
}
ppixiv.send_image_popup = class extends ppixiv.dialog_widget
{
constructor({...options})
{
super({template: "template-send-image", ...options});
// Close if the container is clicked, but not if something inside the container is clicked.
this.container.addEventListener("click", (e) => {
if(e.target != this.container)
return;
this.visible = false;
});
SendImage.add_message_listener("take-image", (message) => {
let tab_id = message.from;
SendImage.send_image(this.illust_id, this.page, [tab_id], "display");
this.visible = false;
});
this.visible = false;
}
show_for_illust(illust_id, page)
{
this.illust_id = illust_id;
this.page = page;
this.visible = true;
}
visibility_changed()
{
if(!this.visible)
{
SendImage.send_message({ message: "hide-send-image" });
return;
}
helpers.interval(() => {
// We should always be visible when this is called.
console.assert(this.visible);
SendImage.send_message({ message: "show-send-image" });
}, 1000, this.visibility_abort.signal);
}
}
ppixiv.send_here_popup = class extends ppixiv.dialog_widget
{
constructor({...options})
{
super({template: "template-send-here", ...options});
this.hide_timer = new helpers.timer(() => { this.visible = false; });
// Show ourself when we see a show-link-tab message and hide if we see a
// hide-link-tab-message.
SendImage.add_message_listener("show-send-image", (message) => {
this.other_tab_id = message.from;
this.hide_timer.set(2000);
this.visible = true;
});
SendImage.add_message_listener("hide-send-image", (message) => {
this.hide_timer.clear();
this.visible = false;
});
this.visible = false;
}
take_image = (e) =>
{
// Send take-image. The sending tab will respond with a send-image message.
SendImage.send_message({ message: "take-image", to: [this.other_tab_id] });
}
visibility_changed()
{
this.hide_timer.clear();
// Hide if we don't see a show-send-image message for a few seconds, as a
// safety in case the other tab dies.
if(this.visible)
{
window.addEventListener("click", this.take_image, { signal: this.visibility_abort.signal });
this.hide_timer.set(2000);
}
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r111/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();
// Set up the pointer_listener singleton.
pointer_listener.install_global_handler();
// 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.scss']);
// 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;
SendImage.init();
// Create the popup menu handler.
this.context_menu = new main_context_menu({container: document.body});
this.link_tabs_popup = new link_tabs_popup({container: document.body});
this.link_this_tab_popup = new link_this_tab_popup({container: document.body});
this.send_here_popup = new send_here_popup({container: document.body});
this.send_image_popup = new send_image_popup({container: document.body});
// Create the main progress bar.
this.progress_bar = new progress_bar(this.container.querySelector(".loading-progress-bar"));
// Create the screens.
this.screen_search = new screen_search({ container: this.container.querySelector(".screen-search-container") });
this.screen_illust = new screen_illust({ container: this.container.querySelector(".screen-illust-container") });
this.screen_manga = new screen_manga({ container: this.container.querySelector(".screen-manga-container") });
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);
// Don't create a new data source if no pages are loaded, which can happen if
// we're loaded viewing an illust. We can start from any page.
let lowest_page = data_source.id_list.get_lowest_loaded_page();
let highest_page = data_source.id_list.get_highest_loaded_page();
if(data_source.id_list.any_pages_loaded && (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", temp_view=false, source=""}={})
{
console.assert(illust_id != null, "Invalid illust_id", illust_id);
let args = helpers.args.location;
// 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(temp_view)
{
args.hash.set("virtual", "1");
args.hash.set("temp-view", "1");
}
else
{
args.hash.delete("virtual");
args.hash.delete("temp-view");
}
helpers.set_page_url(args, add_to_history, "navigation");
}
// Return the displayed screen instance.
get displayed_screen()
{
for(let screen_name in this.screens)
{
var screen = this.screens[screen_name];
if(screen.active)
return screen;
}
return null;
}
_set_active_screen_in_url(args, screen)
{
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");
// If we're going somewhere other than illust, remove zoom state, so
// it's not still around the next time we view an image.
if(screen != "illust")
delete args.state.zoom;
}
// 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 binary resources into blobs, so we don't copy images into every
// place they're used.
async load_resource_blobs()
{
for(let [name, dataURL] of Object.entries(ppixiv.resources))
{
if(!dataURL.startsWith || !dataURL.startsWith("data:"))
continue;
let result = await fetch(dataURL);
let blob = await result.blob();
let blobURL = URL.createObjectURL(blob);
ppixiv.resources[name] = blobURL;
}
}
show_logout_message(force)
{
// Unless forced, don't show the message if we've already shown it recently.
// A session might last for weeks, so we don't want to force it to only be shown
// once, but we don't want to show it repeatedly.
let last_shown = window.sessionStorage.showed_logout_message || 0;
let time_since_shown = Date.now() - last_shown;
let hours_since_shown = time_since_shown / (60*60*1000);
if(!force && hours_since_shown < 6)
return;
window.sessionStorage.showed_logout_message = Date.now();
alert("Please log in to use ppixiv.");
}
temporarily_hide_document()
{
if(document.documentElement != null)
{
document.documentElement.hidden = true;
return;
}
// At this point, none of the document has loaded, and document.body and
// document.documentElement don't exist yet, so we can't hide it. However,
// we want to hide the document as soon as it's added, so we don't flash
// the original page before we have a chance to replace it. Use a mutationObserver
// to detect the document being created.
var observer = new MutationObserver((mutation_list) => {
if(document.documentElement == null)
return;
observer.disconnect();
document.documentElement.hidden = true;
});
observer.observe(document, { attributes: false, childList: true, subtree: true });
};
// When we're disabled, but available on the current page, add the button to enable us.
async setup_disabled_ui(logged_out=false)
{
// Wait for DOMContentLoaded for body.
await helpers.wait_for_content_loaded();
// On most pages, we show our button in the top corner to enable us on that page. Clicking
// it on a search page will switch to us on the same search.
var disabled_ui = helpers.create_node(resources['resources/disabled.html']);
helpers.replace_inlines(disabled_ui);
// 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/r111/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({});