0)
console.log("Removing duplicate illustration IDs:", ids_to_remove.join(", "));
illust_ids = illust_ids.slice();
for(var new_id of ids_to_remove)
{
var idx = illust_ids.indexOf(new_id);
illust_ids.splice(idx, 1);
}
// If there's nothing on this page, don't add it, so this doesn't increase
// get_highest_loaded_page().
// FIXME: If we removed everything, the data source will appear to have reached the last
// page and we won't load any more pages, since thumbnail_view assumes that a page not
// returning any data means we're at the end.
if(illust_ids.length == 0)
return;
this.illust_ids_by_page[page] = illust_ids;
};
// Return the page number illust_id is on, or null if we don't know.
get_page_for_illust(illust_id)
{
for(var page of Object.keys(this.illust_ids_by_page))
{
var ids = this.illust_ids_by_page[page];
page = parseInt(page);
if(ids.indexOf(illust_id) != -1)
return page;
};
return null;
};
// Return the next or previous illustration. If we don't have that page, return null.
//
// This only returns illustrations, skipping over any special entries like user:12345.
get_neighboring_illust_id(illust_id, next)
{
for(let i = 0; i < 100; ++i) // sanity limit
{
illust_id = this._get_neighboring_illust_id_internal(illust_id, next);
if(illust_id == null)
return null;
// If it's not an illustration, keep looking.
if(helpers.id_type(illust_id) == "illust")
return illust_id;
}
return null;
}
// The actual logic for get_neighboring_illust_id, except for skipping entries.
_get_neighboring_illust_id_internal(illust_id, next)
{
var page = this.get_page_for_illust(illust_id);
if(page == null)
return null;
var ids = this.illust_ids_by_page[page];
var idx = ids.indexOf(illust_id);
var new_idx = idx + (next? +1:-1);
if(new_idx < 0)
{
// Return the last illustration on the previous page, or null if that page isn't loaded.
var prev_page_no = page - 1;
var prev_page_illust_ids = this.illust_ids_by_page[prev_page_no];
if(prev_page_illust_ids == null)
return null;
return prev_page_illust_ids[prev_page_illust_ids.length-1];
}
else if(new_idx >= ids.length)
{
// Return the first illustration on the next page, or null if that page isn't loaded.
var next_page_no = page + 1;
var next_page_illust_ids = this.illust_ids_by_page[next_page_no];
if(next_page_illust_ids == null)
return null;
return next_page_illust_ids[0];
}
else
{
return ids[new_idx];
}
};
// Return the page we need to load to get the next or previous illustration. This only
// makes sense if get_neighboring_illust returns null.
get_page_for_neighboring_illust(illust_id, next)
{
var page = this.get_page_for_illust(illust_id);
if(page == null)
return null;
var ids = this.illust_ids_by_page[page];
var idx = ids.indexOf(illust_id);
var new_idx = idx + (next? +1:-1);
if(new_idx >= 0 && new_idx < ids.length)
return page;
page += next? +1:-1;
return page;
};
// Return the first ID, or null if we don't have any.
get_first_id()
{
var keys = Object.keys(this.illust_ids_by_page);
if(keys.length == 0)
return null;
var page = keys[0];
return this.illust_ids_by_page[page][0];
}
// Return true if the given page is loaded.
is_page_loaded(page)
{
return this.illust_ids_by_page[page] != null;
}
};
// A data source asynchronously loads illust_ids to show. The callback will be called
// with:
// {
// 'illust': {
// illust_id1: illust_data1,
// illust_id2: illust_data2,
// ...
// },
// illust_ids: [illust_id1, illust_id2, ...]
// next: function,
// }
//
// Some sources can retrieve user data, some can retrieve only illustration data, and
// some can't retrieve anything but IDs.
//
// The callback will always be called asynchronously, and data_source.callback can be set
// after creation.
//
// If "next" is included, it's a function that can be called to create a new data source
// to load the next page of data. If there are no more pages, next will be null.
// A data source handles a particular source of images, depending on what page we're
// on:
//
// - Retrieves batches of image IDs to display, eg. a single page of bookmark results
// - Load another page of results with load_more()
// - Updates the page URL to reflect the current image
//
// Not all data sources have multiple pages. For example, when we're viewing a regular
// illustration page, we get all of the author's other illust IDs at once, so we just
// load all of them as a single page.
class data_source
{
constructor(url)
{
this.url = new URL(url);
this.id_list = new illust_id_list();
this.update_callbacks = [];
this.loading_pages = {};
this.first_empty_page = -1;
this.update_callbacks = [];
// If this data source supports a start page, store the page we started on.
// This isn't increased as we load more pages, but if we load earlier results
// because the user clicks "load previous results", we'll reduce it.
if(this.supports_start_page)
{
let args = helpers.get_args(url);
this.initial_page = this.get_start_page(args);
console.log("Starting at page", this.initial_page);
}
else
this.initial_page = 1;
};
// If a data source returns a name, we'll display any .data-source-specific elements in
// the thumbnail view with that name.
get name() { return null; }
// Most data sources are for illustrations. This is set to "users" for the followed view.
get search_mode() { return "illusts"; }
// Return a canonical URL for this data source. If the canonical URL is the same,
// the same instance of the data source should be used.
//
// A single data source is used eg. for a particular search and search flags. If
// flags are changed, such as changing filters, a new data source instance is created.
// However, some parts of the URL don't cause a new data source to be used. Return
// a URL with all unrelated parts removed, and with query and hash parameters sorted
// alphabetically.
//
// Due to some quirkiness in data_source_current_illust, this is async.
static async get_canonical_url(url)
{
// Make a copy of the URL.
var url = new URL(url);
this.remove_ignored_url_parts(url);
// Sort query parameters. We don't use multiple parameters with the same key.
url.search = helpers.sort_query_parameters(url.searchParams).toString();
// Sort hash parameters.
var new_hash = helpers.sort_query_parameters(helpers.get_hash_args(url));
helpers.set_hash_args(url, new_hash);
return url.toString();
}
// This is overridden by subclasses to remove parts of the URL that don't affect
// which data source instance is used.
static remove_ignored_url_parts(url)
{
// If p=1 is in the query, it's the page number, which doesn't affect the data source.
url.searchParams.delete("p");
var hash_args = helpers.get_hash_args(url);
// #x=1 is a workaround for iframe loading.
hash_args.delete("x");
// The manga page doesn't affect the data source.
hash_args.delete("page");
// #view=thumbs controls which view is active.
hash_args.delete("view");
// illust_id in the hash is always just telling us which image within the current
// data source to view. data_source_current_illust is different and is handled in
// the subclass.
hash_args.delete("illust_id");
// Any illust_id in the search or the hash doesn't require a new data source.
// bluh
// but the user underneath it does
helpers.set_hash_args(url, hash_args);
}
// startup() is called when the data source becomes active, and shutdown is called when
// it's done. This can be used to add and remove event handlers on the UI.
startup()
{
this.active = true;
}
shutdown()
{
this.active = false;
}
// Load the given page, or the page of the current history state if page is null.
// Call callback when the load finishes.
//
// If we synchronously know that the page doesn't exist, return false and don't
// call callback. Otherwise, return true.
load_page(page)
{
var result = this.loading_pages[page];
if(result == null)
{
// console.log("started loading page", page);
var result = this._load_page_async(page);
this.loading_pages[page] = result;
result.finally(() => {
// console.log("finished loading page", page);
delete this.loading_pages[page];
});
}
return result;
}
// Return true if the given page is either loaded, or currently being loaded by a call to load_page.
is_page_loaded_or_loading(page)
{
if(this.id_list.is_page_loaded(page))
return true;
if(this.loading_pages[page])
return true;
return false;
}
async _load_page_async(page)
{
// Check if we're trying to load backwards too far.
if(page < 1)
{
console.info("No pages before page 1");
return false;
}
// If we know there's no data on this page (eg. we loaded an earlier page before and it
// was empty), don't try to load this one. This prevents us from spamming empty page
// requests.
if(this.first_empty_page != -1 && page >= this.first_empty_page)
{
console.info("No pages after", this.first_empty_page);
return false;
}
// If the page is already loaded, stop.
if(this.id_list.is_page_loaded(page))
return true;
// Check if this is past the end.
if(!this.load_page_available(page))
return false;
// Start the actual load.
var result = await this.load_page_internal(page);
// Reduce the start page, which will update the "load more results" button if any. It's important
// to do this after the await above. If we do it before, it'll update the button before we load
// and cause the button to update before the thumbs. view_search.refresh_images won't be able
// to optimize that and it'll cause uglier refreshes.
if(this.supports_start_page && page < this.initial_page)
this.initial_page = page;
// If there were no results, then we've loaded the last page. Don't try to load
// any pages beyond this.
if(this.id_list.illust_ids_by_page[page] == null)
{
console.log("No data on page", page);
if(this.first_empty_page == -1 || page < this.first_empty_page)
this.first_empty_page = page;
};
return true;
}
// Return the illust_id to display by default.
//
// This should only be called after the initial data is loaded.
get_current_illust_id()
{
// If we have an explicit illust_id in the hash, use it. Note that some pages (in
// particular illustration pages) put this in the query, which is handled in the particular
// data source.
var hash_args = helpers.get_hash_args(document.location);
if(hash_args.has("illust_id"))
return hash_args.get("illust_id");
return this.id_list.get_first_id();
};
// Return the page title to use.
get page_title()
{
return "Pixiv";
}
// This is implemented by the subclass.
async load_page_internal(page)
{
throw "Not implemented";
}
// Return true if page is an available page (not past the end).
//
// We'll always stop if we read a page and it's empty. This allows the extra
// last request to be avoided if we know the last page earlier.
load_page_available(page)
{
return true;
}
// This is called when the currently displayed illust_id changes. The illust_id should
// always have been loaded by this data source, so it should be in id_list. The data
// source should update the history state to reflect the current state.
set_current_illust_id(illust_id, args)
{
if(this.supports_start_page)
{
// Store the page the illustration is on in the hash, so if the page is reloaded while
// we're showing an illustration, we'll start on that page. If we don't do this and
// the user clicks something that came from page 6 while the top of the search results
// were on page 5, we'll start the search at page 5 if the page is reloaded and not find
// the image, which is confusing.
var original_page = this.id_list.get_page_for_illust(illust_id);
if(original_page != null)
this.set_start_page(args, original_page);
}
// By default, put the illust_id in the hash.
args.hash.set("illust_id", illust_id);
}
// Return the estimated number of items per page. This is used to pad the thumbnail
// list to reduce items moving around when we load pages.
get estimated_items_per_page()
{
return 10;
};
// Return the view that should be displayed by default, if no "view" field is in the URL.
get default_view()
{
return "search";
}
// If we're viewing a page specific to a user (an illustration or artist page), return
// the user ID we're viewing. This can change when refreshing the UI.
get viewing_user_id()
{
return null;
};
// Some data sources can restart the search at a page.
get supports_start_page() { return false; }
// Store the current page in the URL.
//
// This is only used if supports_start_page is true.
set_start_page(args, page)
{
args.query.set("p", page);
}
get_start_page(args)
{
let page = args.query.get("p") || "1";
return parseInt(page) || 1;
}
// Add or remove an update listener. These are called when the data source has new data,
// or wants a UI refresh to happen.
add_update_listener(callback)
{
this.update_callbacks.push(callback);
}
remove_update_listener(callback)
{
var idx = this.update_callbacks.indexOf(callback);
if(idx != -1)
this.update_callbacks.splice(idx);
}
// Register a page of data.
add_page(page, illust_ids)
{
this.id_list.add_page(page, illust_ids);
// Call update listeners asynchronously to let them know we have more data.
setTimeout(function() {
this.call_update_listeners();
}.bind(this), 0);
}
call_update_listeners()
{
var callbacks = this.update_callbacks.slice();
for(var callback of callbacks)
{
try {
callback();
} catch(e) {
console.error(e);
}
}
}
// Refresh parts of the UI that are specific to this data source. This is only called
// when first activating a data source, to update things like input fields that shouldn't
// be overwritten on each refresh.
initial_refresh_thumbnail_ui(container, view) { }
// Each data source can have a different UI in the thumbnail view. container is
// the thumbnail-ui-box container to refresh.
refresh_thumbnail_ui(container, view) { }
// A helper for setting up UI links. Find the link with the given data-type,
// set all {key: value} entries as query parameters, and remove any query parameters
// where value is null. Set .selected if the resulting URL matches the current one.
//
// If default_values is present, it tells us the default key that will be used if
// a key isn't present. For example, search.php?s_mode=s_tag is the same as omitting
// s_mode. We prefer to omit it rather than clutter the URL with defaults, but we
// need to know this to figure out whether an item is selected or not.
//
// If a key begins with #, it's placed in the hash rather than the query.
set_item(container, type, fields, default_values)
{
var link = container.querySelector("[data-type='" + type + "']");
if(link == null)
{
console.warn("Couldn't find button with selector", type);
return;
}
// This button is selected if all of the keys it sets are present in the URL.
var button_is_selected = true;
// Adjust the URL for this button.
var url = new URL(document.location);
// Don't include the page number in search buttons, so clicking a filter goes
// back to page 1.
url.searchParams.delete("p");
var hash_args = helpers.get_hash_args(url);
for(var key of Object.keys(fields))
{
var original_key = key;
var value = fields[key];
// If key begins with "#", it means it goes in the hash.
var hash = key.startsWith("#");
if(hash)
key = key.substr(1);
var params = hash? hash_args:url.searchParams;
// The value we're setting in the URL:
var this_value = value;
if(this_value == null && default_values != null)
this_value = default_values[original_key];
// The value currently in the URL:
var selected_value = params.get(key);
if(selected_value == null && default_values != null)
selected_value = default_values[original_key];
// If the URL didn't have the key we're setting, then it isn't selected.
if(this_value != selected_value)
button_is_selected = false;
// If the value we're setting is the default, delete it instead.
if(default_values != null && this_value == default_values[original_key])
value = null;
if(value != null)
params.set(key, value);
else
params.delete(key);
}
helpers.set_hash_args(url, hash_args);
helpers.set_class(link, "selected", button_is_selected);
link.href = url.toString();
};
// 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);
}
}
// Return true of the thumbnail view should show bookmark icons for this source.
get show_bookmark_icons()
{
return true;
}
};
// Load a list of illust IDs, and allow retriving them by page.
function paginate_illust_ids(illust_ids, items_per_page)
{
// Paginate the big list of results.
var pages = [];
var page = null;
for(var illust_id of illust_ids)
{
if(page == null)
{
page = [];
pages.push(page);
}
page.push(illust_id);
if(page.length == items_per_page)
page = null;
}
return pages;
}
// This extends data_source with local pagination.
//
// A few API calls just return all results as a big list of IDs. We can handle loading
// them all at once, but it results in a very long scroll box, which makes scrolling
// awkward. This artificially paginates the results.
class data_source_fake_pagination extends data_source
{
get estimated_items_per_page() { return 30; }
async load_page_internal(page)
{
if(this.pages == null)
{
var illust_ids = await this.load_all_results();
this.pages = paginate_illust_ids(illust_ids, this.estimated_items_per_page);
}
// Register this page.
var illust_ids = this.pages[page-1] || [];
this.add_page(page, illust_ids);
}
// Implemented by the subclass. Load all results, and return the resulting IDs.
async load_all_results()
{
throw "Not implemented";
}
}
// /discovery
//
// This is an actual API call for once, so we don't need to scrape HTML. We only show
// recommended works (we don't have a user view list).
//
// The API call returns 1000 entries. We don't do pagination, we just show the 1000 entries
// and then stop. I haven't checked to see if the API supports returning further pages.
class data_source_discovery extends data_source_fake_pagination
{
get name() { return "discovery"; }
// Implement data_source_fake_pagination:
async load_all_results()
{
// Get "mode" from the URL. If it's not present, use "all".
var query_args = this.url.searchParams;
var mode = query_args.get("mode") || "all";
var data = {
type: "illust",
sample_illusts: "auto",
num_recommendations: 1000,
page: "discovery",
mode: mode,
};
var result = await helpers.get_request("/rpc/recommender.php", data);
// Unlike other APIs, this one returns IDs as ints rather than strings. Convert back
// to strings.
var illust_ids = [];
for(var illust_id of result.recommendations)
illust_ids.push(illust_id + "");
return illust_ids;
};
get page_title() { return "Discovery"; }
get_displaying_text() { return "Recommended Works"; }
refresh_thumbnail_ui(container)
{
// Set .selected on the current mode.
var current_mode = new URL(document.location).searchParams.get("mode") || "all";
helpers.set_class(container.querySelector(".box-link[data-type=all]"), "selected", current_mode == "all");
helpers.set_class(container.querySelector(".box-link[data-type=safe]"), "selected", current_mode == "safe");
helpers.set_class(container.querySelector(".box-link[data-type=r18]"), "selected", current_mode == "r18");
}
}
// Artist suggestions take a random sample of followed users, and query suggestions from them.
// The followed user list normally comes from /discovery/users.
//
// This can also be used to view recommendations based on a specific user. Note that if we're
// doing this, we don't show things like the artist's avatar in the corner, so it doesn't look
// like the images we're showing are by that user.
class data_source_discovery_users extends data_source
{
get name() { return "discovery_users"; }
// The constructor receives the original HTMLDocument.
constructor(url, doc)
{
super(url);
var hash_args = helpers.get_hash_args(this.url);
let user_id = hash_args.get("user_id");
if(user_id != null)
{
this.showing_user_id = user_id;
this.sample_user_ids = [user_id]
}
else
this.sample_user_ids = null;
this.original_doc = doc;
this.original_url = url;
this.seen_user_ids = {};
}
// Return true if the two URLs refer to the same data.
is_same_page(url1, url2)
{
var cleanup_url = function(url)
{
var url = new URL(url);
// Any "x" parameter is a dummy that we set to force the iframe to load, so ignore
// it here.
url.searchParams.delete("x");
// The hash doesn't affect the page that we load.
url.hash = "";
return url.toString();
};
var url1 = cleanup_url(url1);
var url2 = cleanup_url(url2);
return url1 == url2;
}
// We can always return another page.
load_page_available(page)
{
return true;
}
async load_page_internal(page)
{
if(this.showing_user_id != null)
{
// Make sure the user info is loaded.
this.user_info = await image_data.singleton().get_user_info_full(this.showing_user_id);
// Update to refresh our page title, which uses user_info.
this.call_update_listeners();
}
// Find the sample user IDs we need to request suggestions.
await this.load_sample_user_ids();
var data = {
mode: "get_recommend_users_and_works_by_user_ids",
user_ids: this.sample_user_ids.join(","),
user_num: 30,
work_num: 5,
};
// Get suggestions. Each entry is a user, and contains info about a small selection of
// images.
var result = await helpers.get_request("/rpc/index.php", data);
if(result.error)
throw "Error reading suggestions: " + result.message;
// Convert the images into thumbnail_info. Like everything else, this is returned in a format
// slightly different from the other APIs that it's similar to.
let illust_ids = [];
for(let user of result.body)
{
// Each time we load a "page", we're actually just getting a new randomized set of recommendations
// for our seed, so we'll often get duplicate results. Ignore users that we've seen already. id_list
// will remove dupes, but we might get different sample illustrations for a duplicated artist, and
// those wouldn't be removed.
if(this.seen_user_ids[user.user_id])
continue;
this.seen_user_ids[user.user_id] = true;
// Register this as quick user data, for use in thumbnails.
thumbnail_data.singleton().add_quick_user_data(user, "recommendations");
illust_ids.push("user:" + user.user_id);
for(let illust_data of user.illusts)
illust_ids.push(illust_data.illust_id);
}
// Register the new page of data.
this.add_page(page, illust_ids);
}
// Read /discovery/users and set sample_user_ids from userRecommendSampleUser.
async load_sample_user_ids()
{
if(this.sample_user_ids)
return;
// Work around a browser issue: loading an iframe with the same URL as the current page doesn't
// work. (This might have made sense once upon a time when it would always recurse, but today
// this doesn't make sense.) Just add a dummy query to the URL to make sure it's different.
//
// This usually doesn't happen, since we'll normally use this.original_doc if we're reading
// the same page. Skip it if it's not needed, so we don't throw weird URLs at the site if
// we don't have to.
var url = new unsafeWindow.URL(this.original_url);
if(this.is_same_page(url, this.original_url))
url.searchParams.set("x", 1);
// If the underlying page isn't /discovery/users, load it in an iframe to get some data.
let doc = this.original_doc;
if(this.original_doc == null || !this.is_same_page(url, this.original_url))
{
console.log("Loading:", url.toString());
doc = await helpers.load_data_in_iframe(url.toString());
}
// Look for:
//
//
let sample_user_script = null;
for(let script of doc.querySelectorAll("script"))
{
let text = script.innerText;
if(!text.startsWith("pixiv.context.userRecommendSampleUser"))
continue;
sample_user_script = script.innerText;
break;
}
if(sample_user_script == null)
throw "Couldn't find userRecommendSampleUser";
// Pull out the list, and turn it into a JSON array to parse it.
let match = sample_user_script.match(/pixiv.context.userRecommendSampleUser = "(.*)";/);
if(match == null)
throw "Couldn't parse userRecommendSampleUser: " + sample_user_scripts;
this.sample_user_ids = JSON.parse("[" + match[1] + "]");
console.log("Sample user IDs:", this.sample_user_ids);
}
get estimated_items_per_page() { return 30; }
get page_title()
{
if(this.showing_user_id == null)
return "Recommended Users";
if(this.user_info)
return this.user_info.name;
else
return "Loading...";
}
get_displaying_text()
{
if(this.showing_user_id == null)
return "Recommended Users";
if(this.user_info)
return "Similar artists to " + this.user_info.name;
else
return "Illustrations";
};
refresh_thumbnail_ui(container)
{
}
};
// bookmark_detail.php
//
// We use this as an anchor page for viewing recommended illusts for an image, since
// there's no dedicated page for this.
//
// This returns a big chunk of results in one call, so we use data_source_fake_pagination
// to break it up.
class data_source_related_illusts extends data_source_fake_pagination
{
get name() { return "related-illusts"; }
async _load_page_async(page)
{
// The first time we load a page, get info about the source illustration too, so
// we can show it in the UI.
if(!this.fetched_illust_info)
{
this.fetched_illust_info = true;
// Don't wait for this to finish before continuing.
var query_args = this.url.searchParams;
var illust_id = query_args.get("illust_id");
image_data.singleton().get_image_info(illust_id).then((illust_info) => {
this.illust_info = illust_info;
this.call_update_listeners();
}).catch((e) => {
console.error(e);
});
}
return await super._load_page_async(page);
}
// Implement data_source_fake_pagination:
async load_all_results()
{
var query_args = this.url.searchParams;
var illust_id = query_args.get("illust_id");
var data = {
type: "illust",
sample_illusts: illust_id,
num_recommendations: 1000,
};
var result = await helpers.get_request("/rpc/recommender.php", data);
// Unlike other APIs, this one returns IDs as ints rather than strings. Convert back
// to strings.
var illust_ids = [];
for(var illust_id of result.recommendations)
illust_ids.push(illust_id + "");
return illust_ids;
};
get page_title() { return "Similar Illusts"; }
get_displaying_text() { return "Similar Illustrations"; }
refresh_thumbnail_ui(container)
{
// Set the source image.
var source_link = container.querySelector(".image-for-suggestions");
source_link.hidden = this.illust_info == null;
if(this.illust_info)
{
source_link.href = "/artworks/" + this.illust_info.illustId + "#ppixiv";
var img = source_link.querySelector(".image-for-suggestions > img");
img.src = this.illust_info.urls.thumb;
}
}
}
// /ranking.php
//
// This one has an API, and also formats the first page of results into the page.
// They have completely different formats, and the page is updated dynamically (unlike
// the pages we scrape), so we ignore the page for this one and just use the API.
//
// An exception is that we load the previous and next days from the page. This is better
// than using our current date, since it makes sure we have the same view of time as
// the search results.
class data_source_rankings extends data_source
{
constructor(url)
{
super(url);
this.max_page = 999999;
}
get name() { return "rankings"; }
load_page_available(page)
{
return page <= this.max_page;
}
async load_page_internal(page)
{
/*
"mode": "daily",
"content": "all",
"page": 1,
"prev": false,
"next": 2,
"date": "20180923",
"prev_date": "20180922",
"next_date": false,
"rank_total": 500
*/
// Get "mode" from the URL. If it's not present, use "all".
var query_args = this.url.searchParams;
var data = {
format: "json",
p: page,
};
var date = query_args.get("date");
if(date)
data.date = date;
var content = query_args.get("content");
if(content)
data.content = content;
var mode = query_args.get("mode");
if(mode)
data.mode = mode;
var result = await helpers.get_request("/ranking.php", data);
// If "next" is false, this is the last page.
if(!result.next)
this.max_page = Math.min(page, this.max_page);
// Fill in the next/prev dates for the navigation buttons, and the currently
// displayed date.
if(this.today_text == null)
{
this.today_text = result.date;
// This is "YYYYMMDD". Reformat it.
if(this.today_text.length == 8)
{
var year = this.today_text.slice(0,4);
var month = this.today_text.slice(4,6);
var day = this.today_text.slice(6,8);
this.today_text = year + "/" + month + "/" + day;
}
}
if(this.prev_date == null && result.prev_date)
this.prev_date = result.prev_date;
if(this.next_date == null && result.next_date)
this.next_date = result.next_date;
// This returns a struct of data that's like the thumbnails data response,
// but it's not quite the same.
var illust_ids = [];
for(var item of result.contents)
{
// Most APIs return IDs as strings, but this one returns them as ints.
// Convert them to strings.
var illust_id = "" + item.illust_id;
var user_id = "" + item.user_id;
illust_ids.push(illust_id);
}
// Register this as thumbnail data.
thumbnail_data.singleton().loaded_thumbnail_info(result.contents, "rankings");
// Register the new page of data.
this.add_page(page, illust_ids);
};
get estimated_items_per_page() { return 50; }
get page_title() { return "Rankings"; }
get_displaying_text() { return "Rankings"; }
refresh_thumbnail_ui(container)
{
var query_args = this.url.searchParams;
this.set_item(container, "content-all", {content: null});
this.set_item(container, "content-illust", {content: "illust"});
this.set_item(container, "content-ugoira", {content: "ugoira"});
this.set_item(container, "content-manga", {content: "manga"});
this.set_item(container, "mode-daily", {mode: null}, {mode: "daily"});
this.set_item(container, "mode-daily-r18", {mode: "daily_r18"});
this.set_item(container, "mode-r18g", {mode: "r18g"});
this.set_item(container, "mode-weekly", {mode: "weekly"});
this.set_item(container, "mode-monthly", {mode: "monthly"});
this.set_item(container, "mode-rookie", {mode: "rookie"});
this.set_item(container, "mode-original", {mode: "original"});
this.set_item(container, "mode-male", {mode: "male"});
this.set_item(container, "mode-female", {mode: "female"});
if(this.today_text)
container.querySelector(".nav-today").innerText = this.today_text;
// This UI is greyed rather than hidden before we have the dates, so the UI doesn't
// shift around as we load.
var yesterday = container.querySelector(".nav-yesterday");
helpers.set_class(yesterday.querySelector(".box-link"), "disabled", this.prev_date == null);
if(this.prev_date)
{
var url = new URL(window.location);
url.searchParams.set("date", this.prev_date);
yesterday.querySelector("a").href = url;
}
var tomorrow = container.querySelector(".nav-tomorrow");
helpers.set_class(tomorrow.querySelector(".box-link"), "disabled", this.next_date == null);
if(this.next_date)
{
var url = new URL(window.location);
url.searchParams.set("date", this.next_date);
tomorrow.querySelector("a").href = url;
}
// Not all combinations of content and mode exist. For example, there's no ugoira
// monthly, and we'll get an error page if we load it. Hide navigations that aren't
// available. This isn't perfect: if you want to choose ugoira when you're on monthly
// you need to select a different time range first. We could have the content links
// switch to daily if not available...
var available_combinations = [
"all/daily",
"all/daily_r18",
"all/r18g",
"all/weekly",
"all/monthly",
"all/rookie",
"all/original",
"all/male",
"all/female",
"illust/daily",
"illust/daily_r18",
"illust/r18g",
"illust/weekly",
"illust/monthly",
"illust/rookie",
"ugoira/daily",
"ugoira/weekly",
"ugoira/daily_r18",
"manga/daily",
"manga/daily_r18",
"manga/r18g",
"manga/weekly",
"manga/monthly",
"manga/rookie",
];
// Check each link in both checked-links sections.
for(var a of container.querySelectorAll(".checked-links a"))
{
var url = new URL(a.href, document.location);
var link_content = url.searchParams.get("content") || "all";
var link_mode = url.searchParams.get("mode") || "daily";
var name = link_content + "/" + link_mode;
var available = available_combinations.indexOf(name) != -1;
var is_content_link = a.dataset.type.startsWith("content");
if(is_content_link)
{
// If this is a content link (eg. illustrations) and the combination of the
// current time range and this content type isn't available, make this link
// go to daily rather than hiding it, so all content types are always available
// and you don't have to switch time ranges just to select a different type.
if(!available)
{
url.searchParams.delete("mode");
a.href = url;
}
}
else
{
// If this is a mode link (eg. weekly) and it's not available, just hide
// the link.
a.hidden = !available;
}
}
}
}
// This is a base class for data sources that work by loading a regular Pixiv page
// and scraping it.
//
// This wouldn't be needed if we could access the mobile APIs, but for some reason those
// use different authentication tokens and can't be accessed from the website.
//
// All of these work the same way. We keep the current URL (ignoring the hash) synced up
// as a valid page URL that we can load. If we change pages or other search options, we
// modify the URL appropriately.
class data_source_from_page extends data_source
{
// The constructor receives the original HTMLDocument.
constructor(url, doc)
{
super(url);
if(url == null)
throw "url can't be null";
this.original_doc = doc;
this.items_per_page = 1;
// Remember the URL that original_doc came from.
this.original_url = url;
}
// Return true if the two URLs refer to the same data.
is_same_page(url1, url2)
{
var cleanup_url = function(url)
{
var url = new URL(url);
// p=1 and no page at all is the same. Remove p=1 so they compare the same.
if(url.searchParams.get("p") == "1")
url.searchParams.delete("p");
// Any "x" parameter is a dummy that we set to force the iframe to load, so ignore
// it here.
url.searchParams.delete("x");
// The hash doesn't affect the page that we load.
url.hash = "";
return url.toString();
};
var url1 = cleanup_url(url1);
var url2 = cleanup_url(url2);
return url1 == url2;
}
load_page_available(page)
{
return true;
}
async load_page_internal(page)
{
// Our page URL looks like eg.
//
// https://www.pixiv.net/bookmark.php?p=2
//
// possibly with other search options. Request the current URL page data.
var url = new unsafeWindow.URL(this.original_url);
// Update the URL with the current page.
var params = url.searchParams;
params.set("p", page);
if(this.original_doc != null && this.is_same_page(url, this.original_url))
{
this.finished_loading_illust(page, this.original_doc);
return true;
}
// Work around a browser issue: loading an iframe with the same URL as the current page doesn't
// work. (This might have made sense once upon a time when it would always recurse, but today
// this doesn't make sense.) Just add a dummy query to the URL to make sure it's different.
//
// This usually doesn't happen, since we'll normally use this.original_doc if we're reading
// the same page. Skip it if it's not needed, so we don't throw weird URLs at the site if
// we don't have to.
if(this.is_same_page(url, this.original_url))
params.set("x", 1);
url.search = params.toString();
console.log("Loading:", url.toString());
var doc = await helpers.load_data_in_iframe(url.toString());
this.finished_loading_illust(page, doc);
};
get estimated_items_per_page() { return this.items_per_page; }
// We finished loading a page. Parse it and register the results.
finished_loading_illust(page, doc)
{
var illust_ids = this.parse_document(doc);
if(illust_ids == null)
{
// The most common case of there being no data in the document is loading
// a deleted illustration. See if we can find an error message.
console.error("No data on page");
var error = doc.querySelector(".error-message");
var error_message = "Error loading page";
if(error != null)
error_message = error.textContent;
message_widget.singleton.show(error_message);
message_widget.singleton.clear_timer();
return;
}
// Assume that if the first request returns 10 items, all future pages will too. This
// is usually correct unless we happen to load the last page last. Allow this to increase
// in case that happens. (This is only used by the thumbnail view.)
if(this.items_per_page == 1)
this.items_per_page = Math.max(illust_ids.length, this.items_per_page);
// Register the new page of data.
this.add_page(page, illust_ids);
}
// Parse the loaded document and return the illust_ids.
parse_document(doc)
{
throw "Not implemented";
}
};
// There are two ways we can show images for a user: from an illustration page
// (/artworks/#), or from the user's works page (/users/#).
//
// The illustration page is better, since it gives us the ID of every post by the
// user, so we don't have to fetch them page by page, but we have to know the ID
// of a post to get to to that. It's also handy because we can tell where we are
// in the list from the illustration ID without having to know which page we're on,
// the page has the user info encoded (so we don't have to request it separately,
// making loads faster), and if we're going to display a specific illustration, we
// don't need to request it separately either.
//
// However, we can only do searching and filtering on the user page, and that's
// where we land when we load a link to the user.
class data_source_artist extends data_source
{
get name() { return "artist"; }
constructor(url)
{
super(url);
}
get viewing_user_id()
{
var query_args = this.url.searchParams;
let user_id = query_args.get("id");
if(user_id != null)
return query_args.get("id");
let url = new URL(document.location);
url = helpers.get_url_without_language(url);
let parts = url.pathname.split("/");
user_id = parts[2];
return user_id;
};
startup()
{
super.startup();
// While we're active, watch for the tags box to open. We only poopulate the tags
// dropdown if it's opened, so we don't load user tags for every user page.
var popup = document.body.querySelector(".member-tags-box > .popup-menu-box");
this.src_observer = new MutationObserver((mutation_list) => {
if(popup.classList.contains("popup-visible"))
this.tag_list_opened();
});
this.src_observer.observe(popup, { attributes: true });
}
shutdown()
{
super.shutdown();
// Remove our MutationObserver.
this.src_observer.disconnect();
this.src_observer = null;
}
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 hash_args = helpers.get_hash_args(this.url);
var tag = query_args.get("tag") || "";
if(tag == "")
{
// If we're not filtering by tag, use the profile/all request. This returns all of
// the user's illust IDs but no thumb data.
//
// We can use the "illustmanga" code path for this by leaving the tag empty, but
// we do it this way since that's what the site does.
if(this.pages == null)
{
var illust_ids = await this.load_all_results();
this.pages = paginate_illust_ids(illust_ids, this.estimated_items_per_page);
}
// Register this page.
var illust_ids = this.pages[page-1] || [];
this.add_page(page, illust_ids);
}
else
{
// We're filtering by tag.
var type = query_args.get("type");
// For some reason, this API uses a random field in the URL for the type instead of a normal
// query parameter.
var type_for_url =
type == null? "illustmanga":
type == "illust"?"illusts":
"manga";
var url = "/ajax/user/" + this.viewing_user_id + "/" + type_for_url + "/tag";
var result = await helpers.get_request(url, {
tag: tag,
offset: (page-1)*48,
limit: 48,
});
// This data doesn't have profileImageUrl or userName. That's presumably because it's
// used on user pages which get that from user data, but this seems like more of an
// inconsistency than an optimization. Fill it in for thumbnail_data.
for(var item of result.body.works)
{
item.userName = this.user_info.name;
item.profileImageUrl = this.user_info.imageBig;
}
var illust_ids = [];
for(var illust_data of result.body.works)
illust_ids.push(illust_data.id);
// This request returns all of the thumbnail data we need. Forward it to
// thumbnail_data so we don't need to look it up.
thumbnail_data.singleton().loaded_thumbnail_info(result.body.works, "normal");
// Register the new page of data.
this.add_page(page, illust_ids);
}
}
async load_all_results()
{
this.call_update_listeners();
var query_args = this.url.searchParams;
var type = query_args.get("type");
console.error("loading");
var result = await helpers.get_request("/ajax/user/" + this.viewing_user_id + "/profile/all", {});
var illust_ids = [];
if(type == null || type == "illust")
for(var illust_id in result.body.illusts)
illust_ids.push(illust_id);
if(type == null || type == "manga")
for(var illust_id in result.body.manga)
illust_ids.push(illust_id);
// Sort the two sets of IDs back together, putting higher (newer) IDs first.
illust_ids.sort(function(lhs, rhs)
{
return parseInt(rhs) - parseInt(lhs);
});
return illust_ids;
};
refresh_thumbnail_ui(container, thumbnail_view)
{
if(this.user_info)
{
thumbnail_view.avatar_widget.set_from_user_data(this.user_info);
}
this.set_item(container, "artist-works", {type: null});
this.set_item(container, "artist-illust", {type: "illust"});
this.set_item(container, "artist-manga", {type: "manga"});
// Refresh the post tag list.
var query_args = this.url.searchParams;
var current_query = query_args.toString();
var tag_list = container.querySelector(".post-tag-list");
helpers.remove_elements(tag_list);
var add_tag_link = function(tag)
{
var a = document.createElement("a");
a.classList.add("box-link");
a.classList.add("following-tag");
a.innerText = tag;
var url = new URL(document.location);
url.hash = "#ppixiv";
if(tag != "All")
url.searchParams.set("tag", tag);
else
{
url.searchParams.delete("tag");
a.dataset["default"] = 1;
}
a.href = url.toString();
if(url.searchParams.toString() == current_query)
a.classList.add("selected");
tag_list.appendChild(a);
};
if(this.post_tags != null)
{
add_tag_link("All");
for(var tag of this.post_tags || [])
add_tag_link(tag);
}
else
{
// Tags aren't loaded yet. We'll be refreshed after tag_list_opened loads tags.
var span = document.createElement("span");
span.innerText = "Loading...";
tag_list.appendChild(span);
}
// Set whether the tags menu item is highlighted. We don't use set_active_popup_highlight
// here so we don't need to load the tag list.
var box = container.querySelector(".member-tags-box");
helpers.set_class(box, "active", query_args.has("tag"));
}
// This is called when the tag list dropdown is opened.
async tag_list_opened()
{
// Only do this once.
if(this.loaded_tags)
{
console.log("already loaded");
return;
}
this.loaded_tags = true;
// Get user info. We probably have this on this.user_info, but that async load
// might not be finished yet.
var user_info = await image_data.singleton().get_user_info_full(this.viewing_user_id);
console.log("Loading tags for user", user_info.userId);
// Load the user's common tags.
this.post_tags = await this.get_user_tags(user_info);
// If we became inactive before the above request finished, stop.
if(!this.active)
return;
// Trigger refresh_thumbnail_ui to fill in tags.
this.call_update_listeners();
}
async get_user_tags(user_info)
{
if(user_info.frequentTags)
return user_info.frequentTags;
var result = await helpers.get_request("https://www.pixiv.net/ajax/user/" + user_info.userId + "/illustmanga/tags", {});
if(result.error)
{
console.error("Error fetching tags for user " + user_info.userId + ": " + result.error);
user_info.frequentTags = [];
return user_info.frequentTags;
}
// Sort most frequent tags first.
result.body.sort(function(lhs, rhs) {
return rhs.cnt - lhs.cnt;
})
var tags = [];
for(var tag_info of result.body)
tags.push(tag_info.tag);
// Cache the results on the user info.
user_info.frequentTags = tags;
return tags;
}
get page_title()
{
if(this.user_info)
return this.user_info.name;
else
return "Loading...";
}
get_displaying_text()
{
if(this.user_info)
return this.user_info.name + "'s Illustrations";
else
return "Illustrations";
};
}
// Viewing a single illustration.
//
// This page gives us all of the user's illustration IDs, so we can treat this as
// a data source for a user without having to make separate requests.
//
// This reads data from a page, but we don't use data_source_from_page here. We
// don't need its pagination logic, and we do want to have pagination from data_source_fake_pagination.
class data_source_current_illust extends data_source_fake_pagination
{
get name() { return "illust"; }
// The constructor receives the original HTMLDocument.
constructor(url, doc)
{
super(url);
this.original_doc = doc;
this.original_url = url;
}
// Show the illustration by default.
get default_view()
{
return "illust";
}
// Implement data_source_fake_pagination:
async load_all_results()
{
if(this.original_doc != null)
return this.load_all_results_from(this.original_doc);
var url = new unsafeWindow.URL(this.original_url);
// Work around browsers not loading the iframe properly when it has the same URL.
url.searchParams.set("x", 1);
console.log("Loading:", url.toString());
var doc = await helpers.load_data_in_iframe(url.toString());
return this.load_all_results_from(doc);
};
load_all_results_from(doc)
{
var illust_ids = this.parse_document(doc);
if(illust_ids != null)
return illust_ids;
// The most common case of there being no data in the document is loading
// a deleted illustration. See if we can find an error message.
console.error("No data on page");
var error = doc.querySelector(".error-message");
var error_message = "Error loading page";
if(error != null)
error_message = error.textContent;
message_widget.singleton.show(error_message);
message_widget.singleton.clear_timer();
return [];
}
get_preload_data(doc)
{
// The old illustration page used globalInitData. Keep this around for now, in case not all
// users are seeing this yet.
var data = helpers.get_global_init_data(doc);
if(data != null)
return data.preload;
let preload = doc.querySelector("#meta-preload-data");
if(preload == null)
return null;
preload = JSON.parse(preload.getAttribute("content"));
return preload;
}
parse_document(doc)
{
let preload = this.get_preload_data(doc);
if(preload == null)
{
console.error("Couldn't find globalInitData");
return;
}
var illust_id = Object.keys(preload.illust)[0];
var user_id = Object.keys(preload.user)[0];
this.user_info = preload.user[user_id];
var this_illust_data = preload.illust[illust_id];
// Stash the user data so we can use it in get_displaying_text.
this.user_info = preload.user[user_id];
// Add the image list.
var illust_ids = [];
for(var related_illust_id in this_illust_data.userIllusts)
{
if(related_illust_id == illust_id)
continue;
illust_ids.push(related_illust_id);
}
// Make sure our illustration is in the list.
if(illust_ids.indexOf(illust_id) == -1)
illust_ids.push(illust_id);
// Sort newest first.
illust_ids.sort(function(a,b) { return b-a; });
return illust_ids;
};
// Unlike most data_source_from_page implementations, we only have a single page.
get_current_illust_id()
{
// /artworks/#
let url = new URL(document.location);
url = helpers.get_url_without_language(url);
let parts = url.pathname.split("/");
var illust_id = parts[2];
return illust_id;
};
// data_source_current_illust is tricky. Since it returns posts by the user
// of an image, we remove the illust_id (since two images with the same user
// can use the same data source), and add the user ID.
//
// This requires that get_canonical_url be asynchronous, since we might need
// to load the image info.
static async get_canonical_url(url, callback)
{
var url = new URL(url);
url = helpers.get_url_without_language(url);
// /artworks/#
let parts = url.pathname.split("/");
var illust_id = parts[2];
var illust_info = await image_data.singleton().get_image_info(illust_id);
var hash_args = helpers.get_hash_args(url);
hash_args.set("user_id", illust_info.userId);
helpers.set_hash_args(url, hash_args);
// Remove the illustration ID.
url.pathname = "/artworks";
return await data_source.get_canonical_url(url);
}
// Unlike most data sources, data_source_current_illust puts the illust_id
// in the path rather than the hash.
set_current_illust_id(illust_id, args)
{
// Pixiv's inconsistent URLs are annoying. Figure out where the ID field is.
// If the first field is a language, it's the third field (/en/artworks/#), otherwise
// it's the second (/artworks/#).
let parts = args.path.split("/");
let id_part = parts[1].length == 2? 3:2;
parts[id_part] = illust_id;
args.path = parts.join("/");
};
get page_title()
{
if(this.user_info)
return this.user_info.name;
else
return "Illustrations";
}
get_displaying_text()
{
if(this.user_info)
return this.user_info.name + "'s Illustrations";
else
return "Illustrations";
};
refresh_thumbnail_ui(container, thumbnail_view)
{
if(this.user_info)
{
thumbnail_view.avatar_widget.set_from_user_data(this.user_info);
}
}
get page_title()
{
if(this.user_info)
return this.user_info.name;
else
return "Illustrations";
}
get viewing_user_id()
{
if(this.user_info == null)
return null;
return this.user_info.userId;
};
};
// bookmark.php
//
// 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_tags = [];
}
async load_page_internal(page)
{
this.fetch_bookmark_tags();
// Make sure the user info is loaded. This should normally be preloaded by globalInitData
// in main.js, and this won't make a request.
var user_info = await image_data.singleton().get_user_info_full(this.viewing_user_id);
this.user_info = user_info;
this.call_update_listeners();
await this.continue_loading_page_internal(page);
};
get supports_start_page()
{
return true;
}
// If we haven't done so yet, load bookmark tags for this bookmark page. This
// happens in parallel with with page loading.
async fetch_bookmark_tags()
{
if(this.fetched_bookmark_tags)
return;
this.fetched_bookmark_tags = true;
// Fetch bookmark tags. We can do this in parallel with everything else.
var url = "https://www.pixiv.net/ajax/user/" + this.viewing_user_id + "/illusts/bookmark/tags";
var result = await helpers.get_request(url, {});
var tag_counts = {};
for(var bookmark_tag of result.body.public)
{
// Skip "uncategorized". This is always the first entry. There's no clear
// marker for it, so just check the tag name. We don't assume it'll always
// be the first entry in case this changes.
if(bookmark_tag.tag == "未分類")
continue;
tag_counts[bookmark_tag.tag] = parseInt(bookmark_tag.cnt);
}
for(var bookmark_tag of result.body.private)
{
if(bookmark_tag.tag == "未分類")
continue;
if(!(bookmark_tag.tag in tag_counts))
tag_counts[bookmark_tag.tag] = 0;
tag_counts[bookmark_tag.tag] += parseInt(bookmark_tag.cnt);
}
var all_tags = [];
for(var tag in tag_counts)
all_tags.push(tag);
// Sort tags by count, so we can trim just the most used tags.
all_tags.sort(function(lhs, rhs) {
return tag_counts[rhs] - tag_counts[lhs];
});
// Trim the list. Some users will return thousands of tags.
all_tags.splice(20);
all_tags.sort();
this.bookmark_tags = all_tags;
// Update the UI with the tag list.
this.call_update_listeners();
}
// Get API arguments to query bookmarks.
//
// If force_rest isn't null, it's either "show" (public) or "hide" (private), which
// overrides the search parameters.
get_bookmark_query_params(page, force_rest)
{
var query_args = this.url.searchParams;
var rest = query_args.get("rest") || "show";
if(force_rest != null)
rest = force_rest;
var tag = query_args.get("untagged") != null ? "未分類" : query_args.get("tag") || "";
// Load 20 results per page, so our page numbers should match the underlying page if
// the UI is disabled.
return {
tag: tag,
offset: (page-1)*20,
limit: 20,
rest: rest, // public or private (no way to get both)
};
}
// This is implemented by the subclass to do the main loading.
async continue_loading_page_internal(page)
{
throw "Not implemented";
}
get page_title()
{
if(!this.viewing_own_bookmarks())
{
if(this.user_info)
return this.user_info.name + "'s Bookmarks";
else
return "Loading...";
}
return "Bookmarks";
}
get_displaying_text()
{
if(!this.viewing_own_bookmarks())
{
if(this.user_info)
return this.user_info.name + "'s Bookmarks";
return "User's Bookmarks";
}
var query_args = this.url.searchParams;
var hash_args = helpers.get_hash_args(this.url);
var private_bookmarks = query_args.get("rest") == "hide";
var displaying = this.viewing_all_bookmarks? "All Bookmarks":
private_bookmarks? "Private Bookmarks":"Public Bookmarks";
var tag = query_args.get("tag");
if(tag)
displaying += " with tag \"" + tag + "\"";
return displaying;
};
get viewing_all_bookmarks() { return false; }
refresh_thumbnail_ui(container, thumbnail_view)
{
// The public/private button only makes sense when viewing your own bookmarks.
var public_private_button_container = container.querySelector(".bookmarks-public-private");
public_private_button_container.hidden = !this.viewing_own_bookmarks();
// Set up the public and private buttons.
this.set_item(public_private_button_container, "all", {"#show-all": 1}, {"#show-all": 1});
this.set_item(container, "public", {rest: null, "#show-all": 0}, {"#show-all": 1});
this.set_item(container, "private", {rest: "hide", "#show-all": 0}, {"#show-all": 1});
// Refresh the bookmark tag list. Remove the page number from these buttons.
let current_url = new URL(document.location);
current_url.searchParams.delete("p");
let current_query = current_url.searchParams.toString();
var tag_list = container.querySelector(".bookmark-tag-list");
helpers.remove_elements(tag_list);
var add_tag_link = function(tag)
{
var a = document.createElement("a");
a.classList.add("box-link");
a.classList.add("following-tag");
a.innerText = tag;
var url = new URL(document.location);
url.searchParams.delete("p");
if(tag == "Uncategorized")
url.searchParams.set("untagged", 1);
else
url.searchParams.delete("untagged", 1);
if(tag != "All" && tag != "Uncategorized")
url.searchParams.set("tag", tag);
else
url.searchParams.delete("tag");
a.href = url.toString();
if(url.searchParams.toString() == current_query)
a.classList.add("selected");
tag_list.appendChild(a);
};
add_tag_link("All");
add_tag_link("Uncategorized");
for(var tag of this.bookmark_tags || [])
add_tag_link(tag);
if(this.user_info)
thumbnail_view.avatar_widget.set_from_user_data(this.user_info);
}
get viewing_user_id()
{
// If there's no user ID in the URL, view our own bookmarks.
var query_args = this.url.searchParams;
var user_id = query_args.get("id");
if(user_id == null)
return window.global_data.user_id;
return query_args.get("id");
};
// Return true if we're viewing our own bookmarks.
viewing_own_bookmarks()
{
return this.viewing_user_id == window.global_data.user_id;
}
// Don't show bookmark icons for the user's own bookmarks. Every image on that page
// is bookmarked, so it's just a lot of noise.
get show_bookmark_icons()
{
return !this.viewing_own_bookmarks();
}
}
// Normal bookmark querying. This can only retrieve public or private bookmarks,
// and not both.
class data_source_bookmarks extends data_source_bookmarks_base
{
async continue_loading_page_internal(page)
{
var data = this.get_bookmark_query_params(page);
var url = "/ajax/user/" + this.viewing_user_id + "/illusts/bookmarks";
var result = await helpers.get_request(url, data);
var illust_ids = [];
for(var illust_data of result.body.works)
illust_ids.push(illust_data.id);
// This request returns all of the thumbnail data we need. Forward it to
// thumbnail_data so we don't need to look it up.
thumbnail_data.singleton().loaded_thumbnail_info(result.body.works, "normal");
// Register the new page of data.
this.add_page(page, illust_ids);
}
};
// Merged bookmark querying. This makes queries for both public and private bookmarks,
// and merges them together.
class data_source_bookmarks_merged extends data_source_bookmarks_base
{
get viewing_all_bookmarks() { return true; }
constructor(url)
{
super(url);
this.max_page_per_type = [-1, -1]; // public, private
this.bookmark_illust_ids = [[], []]; // public, private
}
async continue_loading_page_internal(page)
{
// Request both the public and private bookmarks on the given page. If we've
// already reached the end of either of them, don't send that request.
var request1 = this.request_bookmarks(page, "show");
var request2 = this.request_bookmarks(page, "hide");
// Wait for both requests to finish.
await Promise.all([request1, request2]);
// Both requests finished. Combine the two lists of illust IDs into a single page
// and register it.
var illust_ids = [];
for(var i = 0; i < 2; ++i)
if(this.bookmark_illust_ids[i] != null && this.bookmark_illust_ids[i][page] != null)
illust_ids = illust_ids.concat(this.bookmark_illust_ids[i][page]);
this.add_page(page, illust_ids);
}
async request_bookmarks(page, rest)
{
var is_private = rest == "hide"? 1:0;
var max_page = this.max_page_per_type[is_private];
if(max_page != -1 && page > max_page)
{
// We're past the end.
console.log("page", page, "beyond", max_page, rest);
return;
}
var data = this.get_bookmark_query_params(page, rest);
var url = "/ajax/user/" + this.viewing_user_id + "/illusts/bookmarks";
var result = await helpers.get_request(url, data);
// Put higher (newer) bookmarks first.
result.body.works.sort(function(lhs, rhs)
{
return parseInt(rhs.bookmarkData.id) - parseInt(lhs.bookmarkData.id);
});
var illust_ids = [];
for(var illust_data of result.body.works)
illust_ids.push(illust_data.id);
// This request returns all of the thumbnail data we need. Forward it to
// thumbnail_data so we don't need to look it up.
thumbnail_data.singleton().loaded_thumbnail_info(result.body.works, "normal");
// If there are no results, remember that this is the last page, so we don't
// make more requests for this type.
if(illust_ids.length == 0)
{
if(this.max_page_per_type[is_private] == -1)
this.max_page_per_type[is_private] = page;
else
this.max_page_per_type[is_private] = Math.min(page, this.max_page_per_type[is_private]);
// console.log("max page for", is_private? "private":"public", this.max_page_per_type[is_private]);
}
// Store the IDs. We don't register them here.
this.bookmark_illust_ids[is_private][page] = illust_ids;
}
}
// new_illust.php
class data_source_new_illust extends data_source
{
get name() { return "new_illust"; }
get page_title()
{
return "New Works";
}
get_displaying_text()
{
return "New Works";
};
async load_page_internal(page)
{
var query_args = this.url.searchParams;
var hash_args = helpers.get_hash_args(this.url);
// new_illust.php or new_illust_r18.php:
let r18 = document.location.pathname == "/new_illust_r18.php";
var type = query_args.get("type") || "illust";
// Everything Pixiv does has always been based on page numbers, but this one uses starting IDs.
// That's a better way (avoids duplicates when moving forward in the list), but it's inconsistent
// with everything else. We usually load from page 1 upwards. If we're loading the next page and
// we have a previous last_id, assume it starts at that ID.
//
// This makes some assumptions about how we're called: that we won't be called for the same page
// multiple times and we're always loaded in ascending order. In practice this is almost always
// true. If Pixiv starts using this method for more important pages it might be worth checking
// this more carefully.
if(this.last_id == null)
{
this.last_id = 0;
this.last_id_page = 1;
}
if(this.last_id_page != page)
{
console.error("Pages weren't loaded in order");
return;
}
console.log("Assuming page", page, "starts at", this.last_id);
var url = "/ajax/illust/new";
var result = await helpers.get_request(url, {
limit: 20,
type: type,
r18: r18,
lastId: this.last_id,
});
var illust_ids = [];
for(var illust_data of result.body.illusts)
illust_ids.push(illust_data.id);
if(illust_ids.length > 0)
{
this.last_id = illust_ids[illust_ids.length-1];
this.last_id_page++;
}
// This request returns all of the thumbnail data we need. Forward it to
// thumbnail_data so we don't need to look it up.
thumbnail_data.singleton().loaded_thumbnail_info(result.body.illusts, "illust_new");
// Register the new page of data.
this.add_page(page, illust_ids);
}
refresh_thumbnail_ui(container)
{
this.set_item(container, "new-illust-type-illust", {type: null});
this.set_item(container, "new-illust-type-manga", {type: "manga"});
// These links are different from anything else on the site: they switch between
// two top-level pages, even though they're just flags and everything else is the
// same. We don't actually need to do this since we're just making API calls, but
// we try to keep the base URLs compatible, so we go to the equivalent page on Pixiv
// if we're turned off.
var all_ages_link = container.querySelector("[data-type='new-illust-ages-all']");
var r18_link = container.querySelector("[data-type='new-illust-ages-r18']");
var button_is_selected = true;
var url = new URL(document.location);
url.pathname = "/new_illust.php";
all_ages_link.href = url;
var url = new URL(document.location);
url.pathname = "/new_illust_r18.php";
r18_link.href = url;
var url = new URL(document.location);
var currently_all_ages = url.pathname == "/new_illust.php";
helpers.set_class(currently_all_ages? all_ages_link:r18_link, "selected", button_is_selected);
}
}
// bookmark_new_illust.php
class data_source_bookmarks_new_illust extends data_source_from_page
{
get name() { return "bookmarks_new_illust"; }
constructor(url, doc)
{
super(url, doc);
this.bookmark_tags = [];
}
// Parse the loaded document and return the illust_ids.
parse_document(doc)
{
this.bookmark_tags = [];
for(var element of doc.querySelectorAll(".menu-items a[href*='bookmark_new_illust.php?tag'] span.icon-text"))
this.bookmark_tags.push(element.innerText);
var element = doc.querySelector("#js-mount-point-latest-following");
var items = JSON.parse(element.dataset.items);
// Populate thumbnail data with this data.
thumbnail_data.singleton().loaded_thumbnail_info(items, "following");
var illust_ids = [];
for(var illust of items)
illust_ids.push(illust.illustId);
return illust_ids;
}
get page_title()
{
return "Following";
}
get_displaying_text()
{
return "Following";
};
refresh_thumbnail_ui(container)
{
// Refresh the bookmark tag list.
var current_tag = new URL(document.location).searchParams.get("tag") || "All";
var tag_list = container.querySelector(".bookmark-tag-list");
helpers.remove_elements(tag_list);
var add_tag_link = function(tag)
{
var a = document.createElement("a");
a.classList.add("box-link");
a.classList.add("following-tag");
a.innerText = tag;
var url = new URL(document.location);
if(tag != "All")
url.searchParams.set("tag", tag);
else
url.searchParams.delete("tag");
a.href = url.toString();
if(tag == current_tag)
a.classList.add("selected");
tag_list.appendChild(a);
};
add_tag_link("All");
for(var tag of this.bookmark_tags)
add_tag_link(tag);
}
};
// /tags
//
// The new tag search UI is a bewildering mess:
//
// - Searching for a tag goes to "/tags/tag/artworks". The "top" tab is highlighted,
// but it's not really on that section and no tab actually goes here. The API query
// is "/ajax/search/artworks/TAG". "Illustrations, Manga, Ugoira" in the 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 seems to give identical
// results to "artworks".
//
// This is "イラスト・うごくイラスト" in the search options and isn't translated. This
// page seems like a bug.
//
// - 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. At least this one makes sense.
//
// - You can search for just animations, but there's no button for it in the UI. You
// have to pick it from the dropdown in search options. This one is "illustrations?type=ugoira".
// Why did they keep using type just for one search mode? Saying "type=manga" or any
// other type fails, so it really is just used for this.
//
// - Clicking "Top" goes to "/tags/tag" with no type. This is a completely different
// page and API, "/ajax/search/top/tag". It doesn't actually seem to be a rankings
// page and just shows the same thing as the others with a different layout, so we
// ignore this and treat it like "artworks".
class data_source_search extends data_source
{
get name() { return "search"; }
constructor(url, doc)
{
super(url, doc);
this.cache_search_title = this.cache_search_title.bind(this);
// Add the search tags to tag history. We only do this at the start when the
// data source is created, not every time we navigate back to the search.
let tag = this._search_tags;
if(tag)
helpers.add_recent_search_tag(tag);
this.cache_search_title();
}
get _search_tags()
{
return helpers._get_search_tags_from_url(this.url);
}
// Return the search type from the URL. This is one of "artworks", "illustrations"
// or "novels" (not supported"). It can also be omitted, which is the "top" page,
// but that gives the same results as "artworks" with a different page layout, so
// we treat it as "artworks".
get _search_type()
{
// ["", "tags", tag list, type]
let url = helpers.get_url_without_language(this.url);
let parts = url.pathname.split("/");
if(parts.length >= 4)
return parts[3];
else
return "artworks";
}
startup()
{
super.startup();
// Refresh our title when translations are toggled.
settings.register_change_callback("disable-translations", this.cache_search_title);
}
shutdown()
{
super.shutdown();
settings.unregister_change_callback("disable-translations", this.cache_search_title);
}
async cache_search_title()
{
this.title = "Search: ";
let tags = this._search_tags;
if(tags)
{
tags = await tag_translations.get().translate_tag_list(tags, "en");
this.title += tags;
}
// 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". They seem to return the
// same thing, so we always use "illustrations". "artworks" doesn't use the type field.
let search_type = this._search_type;
let api_search_type = "artworks";
if(search_type == "artworks" || 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 + "/" + tag;
var result = await helpers.get_request(url, args);
let body = result.body;
// Store related tags. Only do this the first time and don't change it when we read
// future pages, so the tags don't keep changing as you scroll around.
if(this.related_tags == null)
{
this.related_tags = [];
for(let tag of body.relatedTags)
this.related_tags.push({tag: tag});
this.call_update_listeners();
}
// Add translations. This is inconsistent with their other translation APIs, because Pixiv
// never uses the same interface twice. Also, this has translations only for related tags
// above, not for the tags used in the search, which sucks.
let translations = [];
for(let tag of Object.keys(body.tagTranslation))
{
translations.push({
tag: tag,
translation: body.tagTranslation[tag],
});
}
tag_translations.get().add_translations(translations);
// /tag/TAG/illustrations returns results in body.illust.
// /tag/TAG/artworks returns results in body.illustManga.
// /tag/TAG/manga returns results in body.manga.
let illusts = body.illust || body.illustManga || body.manga;
illusts = illusts.data;
// Populate thumbnail data with this data.
thumbnail_data.singleton().loaded_thumbnail_info(illusts, "search");
var illust_ids = [];
for(let illust of illusts)
illust_ids.push(illust.illustId);
// Register the new page of data.
this.add_page(page, illust_ids);
}
get page_title()
{
return this.title;
}
get_displaying_text()
{
return this.title;
};
initial_refresh_thumbnail_ui(container, view)
{
// Fill the search box with the current tag.
var query_args = this.url.searchParams;
let tag = this._search_tags;
container.querySelector(".search-page-tag-entry .search-tags").value = tag;
}
// Return the search mode, which is selected by the "Type" search option. This generally
// corresponds to the underlying page's search modes.
get_url_search_mode()
{
// "/tags/tag/illustrations" has a "type" parameter with the search type. This is used for
// "illust" (everything except animations) and "ugoira".
let search_type = this._search_type;
if(search_type == "illustrations")
{
let query_search_type = this.url.searchParams.get("type");
if(query_search_type == "ugoira") return "ugoira";
if(query_search_type == "illust") return "illust";
// If there's no parameter, show everything.
return "all";
}
if(search_type == "artworks")
return "all";
if(search_type == "manga")
return "manga";
// Use "all" for unrecognized types.
return "all";
}
// Return URL with the search mode set to mode.
set_url_search_mode(url, mode)
{
url = new URL(url);
url = helpers.get_url_without_language(url);
// Only "ugoira" searches use type in the query. It causes an error in other modes, so remove it.
if(mode == "illust")
url.searchParams.set("type", "illust");
else if(mode == "ugoira")
url.searchParams.set("type", "ugoira");
else
url.searchParams.delete("type");
let search_type = "artworks";
if(mode == "manga")
search_type = "manga";
else if(mode == "ugoira" || mode == "illust")
search_type = "illustrations";
// Set the type in the URL.
let parts = url.pathname.split("/");
parts[3] = search_type;
url.pathname = parts.join("/");
return url;
}
refresh_thumbnail_ui(container, thumbnail_view)
{
if(this.related_tags)
{
thumbnail_view.tag_widget.set({
tags: this.related_tags
});
}
this.set_item(container, "ages-all", {mode: null});
this.set_item(container, "ages-safe", {mode: "safe"});
this.set_item(container, "ages-r18", {mode: "r18"});
this.set_item(container, "order-newest", {order: null}, {order: "date_d"});
this.set_item(container, "order-oldest", {order: "date"});
this.set_item(container, "order-all", {order: "popular_d"});
this.set_item(container, "order-male", {order: "popular_male_d"});
this.set_item(container, "order-female", {order: "popular_female_d"});
let set_search_mode = (container, type, mode) =>
{
var link = container.querySelector("[data-type='" + type + "']");
if(link == null)
{
console.warn("Couldn't find button with selector", type);
return;
}
let current_mode = this.get_url_search_mode();
let button_is_selected = current_mode == mode;
helpers.set_class(link, "selected", button_is_selected);
// Adjust the URL for this button.
let url = this.set_url_search_mode(document.location, mode);
link.href = url.toString();
};
set_search_mode(container, "search-type-all", "all");
set_search_mode(container, "search-type-illust", "illust");
set_search_mode(container, "search-type-manga", "manga");
set_search_mode(container, "search-type-ugoira", "ugoira");
this.set_item(container, "search-all", {s_mode: null}, {s_mode: "s_tag"});
this.set_item(container, "search-exact", {s_mode: "s_tag_full"});
this.set_item(container, "search-text", {s_mode: "s_tc"});
this.set_item(container, "res-all", {wlt: null, hlt: null, wgt: null, hgt: null});
this.set_item(container, "res-high", {wlt: 3000, hlt: 3000, wgt: null, hgt: null});
this.set_item(container, "res-medium", {wlt: 1000, hlt: 1000, wgt: 2999, hgt: 2999});
this.set_item(container, "res-low", {wlt: null, hlt: null, wgt: 999, hgt: 999});
this.set_item(container, "aspect-ratio-all", {ratio: null});
this.set_item(container, "aspect-ratio-landscape", {ratio: "0.5"});
this.set_item(container, "aspect-ratio-portrait", {ratio: "-0.5"});
this.set_item(container, "aspect-ratio-square", {ratio: "0"});
this.set_item(container, "bookmarks-all", {blt: null, bgt: null});
this.set_item(container, "bookmarks-5000", {blt: 5000, bgt: null});
this.set_item(container, "bookmarks-2500", {blt: 2500, bgt: null});
this.set_item(container, "bookmarks-1000", {blt: 1000, bgt: null});
this.set_item(container, "bookmarks-500", {blt: 500, bgt: null});
this.set_item(container, "bookmarks-250", {blt: 250, bgt: null});
this.set_item(container, "bookmarks-100", {blt: 100, bgt: null});
// The time filter is a range, but I'm not sure what time zone it filters in
// (presumably either JST or UTC). There's also only a date and not a time,
// which means you can't actually filter "today", since there's no way to specify
// which "today" you mean. So, we offer filtering starting at "this week",
// and you can just use the default date sort if you want to see new posts.
// For "this week", we set the end date a day in the future to make sure we
// don't filter out posts today.
this.set_item(container, "time-all", {scd: null, ecd: null});
var format_date = function(date)
{
var f = (date.getYear() + 1900).toFixed();
return (date.getYear() + 1900).toFixed().padStart(2, "0") + "-" +
(date.getMonth() + 1).toFixed().padStart(2, "0") + "-" +
date.getDate().toFixed().padStart(2, "0");
};
var set_date_filter = function(name, start, end)
{
var start_date = format_date(start);
var end_date = format_date(end);
this.set_item(container, name, {scd: start_date, ecd: end_date});
}.bind(this);
var tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1);
var last_week = new Date(); last_week.setDate(last_week.getDate() - 7);
var last_month = new Date(); last_month.setMonth(last_month.getMonth() - 1);
var last_year = new Date(); last_year.setFullYear(last_year.getFullYear() - 1);
set_date_filter("time-week", last_week, tomorrow);
set_date_filter("time-month", last_month, tomorrow);
set_date_filter("time-year", last_year, tomorrow);
for(var years_ago = 1; years_ago <= 7; ++years_ago)
{
var start_year = new Date(); start_year.setFullYear(start_year.getFullYear() - years_ago - 1);
var end_year = new Date(); end_year.setFullYear(end_year.getFullYear() - years_ago);
set_date_filter("time-years-ago-" + years_ago, start_year, end_year);
}
this.set_active_popup_highlight(container, [".ages-box", ".popularity-box", ".type-box", ".search-mode-box", ".size-box", ".aspect-ratio-box", ".bookmarks-box", ".time-box", ".member-tags-box"]);
// The "reset search" button removes everything in the query except search terms, and resets
// the search type.
var box = container.querySelector(".reset-search");
var url = new URL(document.location);
let tag = helpers._get_search_tags_from_url(url);
url.search = "";
if(tag == null)
url.pathname = "/tags";
else
url.pathname = "/tags/" + encodeURIComponent(tag) + "/artworks";
box.href = url;
}
};
class data_source_follows extends data_source
{
get name() { return "following"; }
get search_mode() { return "users"; }
constructor(url)
{
super(url);
}
get viewing_user_id()
{
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";
var result = await helpers.get_request(url, {
offset: 20*(page-1),
limit: 20,
rest: rest,
});
// 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(illust.id);
console.log(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(illusts, "normal");
// Register the new page of data.
this.add_page(page, illust_ids);
}
refresh_thumbnail_ui(container, thumbnail_view)
{
if(this.user_info)
{
thumbnail_view.avatar_widget.set_from_user_data(this.user_info);
}
// The public/private button only makes sense when viewing your own follows.
var public_private_button_container = container.querySelector(".follows-public-private");
public_private_button_container.hidden = !this.viewing_self;
this.set_item(container, "public-follows", {rest: "show"}, {rest: "show"});
this.set_item(container, "private-follows", {rest: "hide"}, {rest: "show"});
}
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";
};
}
// This is a simple hack to piece together an MJPEG MKV from a bunch of JPEGs.
var encode_mkv = (function() {
var encode_length = function(value)
{
// Encode a 40-bit EBML int. This lets us encode 32-bit ints with no extra logic.
return struct(">BI").pack(0x08, value);
};
var header_int = function(container, identifier, value)
{
container.push(new Uint8Array(identifier));
var data = struct(">II").pack(0, value);
var size = data.byteLength;
container.push(encode_length(size));
container.push(data);
};
var header_float = function(container, identifier, value)
{
container.push(new Uint8Array(identifier));
var data = struct(">f").pack(value);
var size = data.byteLength;
container.push(encode_length(size));
container.push(data);
};
var header_data = function(container, identifier, data)
{
container.push(new Uint8Array(identifier));
container.push(encode_length(data.byteLength));
container.push(data);
};
// Return the total size of an array of ArrayBuffers.
var total_size = function(array)
{
var size = 0;
for(var idx = 0; idx < array.length; ++idx)
{
var item = array[idx];
size += item.byteLength;
}
return size;
};
var append_array = function(a1, a2)
{
var result = new Uint8Array(a1.byteLength + a2.byteLength);
result.set(new Uint8Array(a1));
result.set(new Uint8Array(a2), a1.byteLength);
return result;
};
// Create an EBML block from an identifier and a list of Uint8Array parts. Return a
// single Uint8Array.
var create_data_block = function(identifier, parts)
{
var identifier = new Uint8Array(identifier);
var data_size = total_size(parts);
var encoded_data_size = encode_length(data_size);
var result = new Uint8Array(identifier.byteLength + encoded_data_size.byteLength + data_size);
var pos = 0;
result.set(new Uint8Array(identifier), pos);
pos += identifier.byteLength;
result.set(new Uint8Array(encoded_data_size), pos);
pos += encoded_data_size.byteLength;
for(var i = 0; i < parts.length; ++i)
{
var part = parts[i];
result.set(new Uint8Array(part), pos);
pos += part.byteLength;
}
return result;
};
// EBML data types
var ebml_header = function()
{
var parts = [];
header_int(parts, [0x42, 0x86], 1); // EBMLVersion
header_int(parts, [0x42, 0xF7], 1); // EBMLReadVersion
header_int(parts, [0x42, 0xF2], 4); // EBMLMaxIDLength
header_int(parts, [0x42, 0xF3], 8); // EBMLMaxSizeLength
header_data(parts, [0x42, 0x82], new Uint8Array([0x6D, 0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61])); // DocType ("matroska")
header_int(parts, [0x42, 0x87], 4); // DocTypeVersion
header_int(parts, [0x42, 0x85], 2); // DocTypeReadVersion
return create_data_block([0x1A, 0x45, 0xDF, 0xA3], parts); // EBML
};
var ebml_info = function(duration)
{
var parts = [];
header_int(parts, [0x2A, 0xD7, 0xB1], 1000000); // TimecodeScale
header_data(parts, [0x4D, 0x80], new Uint8Array([120])); // MuxingApp ("x") (this shouldn't be mandatory)
header_data(parts, [0x57, 0x41], new Uint8Array([120])); // WritingApp ("x") (this shouldn't be mandatory)
header_float(parts, [0x44, 0x89], duration * 1000); // Duration (why is this a float?)
return create_data_block([0x15, 0x49, 0xA9, 0x66], parts); // Info
};
var ebml_track_entry_video = function(width, height)
{
var parts = [];
header_int(parts, [0xB0], width); // PixelWidth
header_int(parts, [0xBA], height); // PixelHeight
return create_data_block([0xE0], parts); // Video
};
var ebml_track_entry = function(width, height)
{
var parts = [];
header_int(parts, [0xD7], 1); // TrackNumber
header_int(parts, [0x73, 0xC5], 1); // TrackUID
header_int(parts, [0x83], 1); // TrackType (video)
header_int(parts, [0x9C], 0); // FlagLacing
header_int(parts, [0x23, 0xE3, 0x83], 33333333); // DefaultDuration (overridden per frame)
header_data(parts, [0x86], new Uint8Array([0x56, 0x5f, 0x4d, 0x4a, 0x50, 0x45, 0x47])); // CodecID ("V_MJPEG")
parts.push(ebml_track_entry_video(width, height));
return create_data_block([0xAE], parts); // TrackEntry
};
var ebml_tracks = function(width, height)
{
var parts = [];
parts.push(ebml_track_entry(width, height));
return create_data_block([0x16, 0x54, 0xAE, 0x6B], parts); // Tracks
};
var ebml_simpleblock = function(frame_data)
{
// We should be able to use encode_length(1), but for some reason, while everything else
// handles our non-optimal-length ints just fine, this field doesn't. Manually encode it
// instead.
var result = new Uint8Array([
0x81, // track number 1 (EBML encoded)
0, 0, // timecode relative to cluster
0x80, // flags (keyframe)
]);
result = append_array(result, frame_data);
return result;
};
var ebml_cluster = function(frame_data, frame_time)
{
var parts = [];
header_int(parts, [0xE7], Math.round(frame_time * 1000)); // Timecode
header_data(parts, [0xA3], ebml_simpleblock(frame_data)); // SimpleBlock
return create_data_block([0x1F, 0x43, 0xB6, 0x75], parts); // Cluster
};
var ebml_cue_track_positions = function(file_position)
{
var parts = [];
header_int(parts, [0xF7], 1); // CueTrack
header_int(parts, [0xF1], file_position); // CueClusterPosition
return create_data_block([0xB7], parts); // CueTrackPositions
};
var ebml_cue_point = function(frame_time, file_position)
{
var parts = [];
header_int(parts, [0xB3], Math.round(frame_time * 1000)); // CueTime
parts.push(ebml_cue_track_positions(file_position));
return create_data_block([0xBB], parts); // CuePoint
};
var ebml_cues = function(frame_times, frame_file_positions)
{
var parts = [];
for(var frame = 0; frame < frame_file_positions.length; ++frame)
{
var frame_time = frame_times[frame];
var file_position = frame_file_positions[frame];
parts.push(ebml_cue_point(frame_time, file_position));
}
return create_data_block([0x1C, 0x53, 0xBB, 0x6B], parts); // Cues
};
var ebml_segment = function(parts)
{
return create_data_block([0x18, 0x53, 0x80, 0x67], parts); // Segment
};
// API:
// We don't decode the JPEG frames while we do this, so the resolution is supplied here.
class encode_mkv
{
constructor(width, height)
{
this.width = width;
this.height = height;
this.frames = [];
}
add(jpeg_data, frame_duration_ms)
{
this.frames.push({
data: jpeg_data,
duration: frame_duration_ms,
});
};
build()
{
// Sum the duration of the video.
var duration = 0;
for(var frame = 0; frame < this.frames.length; ++frame)
{
var data = this.frames[frame].data;
var ms = this.frames[frame].duration;
duration += ms / 1000.0;
}
var header_parts = ebml_header();
var parts = [];
parts.push(ebml_info(duration));
parts.push(ebml_tracks(this.width, this.height));
// current_pos is the relative position from the start of the segment (after the ID and
// size bytes) to the beginning of the cluster.
var current_pos = 0;
for(var part of parts)
current_pos += part.byteLength;
// Create each frame as its own cluster, and keep track of the file position of each.
var frame_file_positions = [];
var frame_file_times = [];
var frame_time = 0;
for(var frame = 0; frame < this.frames.length; ++frame)
{
var data = this.frames[frame].data;
var ms = this.frames[frame].duration;
var cluster = ebml_cluster(data, frame_time);
parts.push(cluster);
frame_file_positions.push(current_pos);
frame_file_times.push(frame_time);
frame_time += ms / 1000.0;
current_pos += cluster.byteLength;
};
// Add the frame index.
parts.push(ebml_cues(frame_file_times, frame_file_positions));
// Create an EBMLSegment containing all of the parts (excluding the header).
var segment = ebml_segment(parts);
// Return a blob containing the final data.
var file = [];
file = file.concat(header_parts);
file = file.concat(segment);
return new Blob(file);
};
};
return encode_mkv;
})();
// Hide the mouse cursor when it hasn't moved briefly, to get it out of the way.
// This only hides the cursor over element.
//
// Chrome's cursor handling is buggy and doesn't update the cursor when it's not
// moving, so this only works in Firefox.
class hide_mouse_cursor_on_idle
{
constructor(element)
{
this.onmousemove = this.onmousemove.bind(this);
this.onblur = this.onblur.bind(this);
this.idle = this.idle.bind(this);
this.hide_immediately = this.hide_immediately.bind(this);
this.element = element;
this.force_hidden_until = null;
this.cursor_hidden = false;
window.addEventListener("mousemove", this.onmousemove, true);
window.addEventListener("blur", this.blur, true);
window.addEventListener("hide-cursor-immediately", this.hide_immediately, true);
window.addEventListener("enable-hiding-cursor", function() { this.enable = true; }.bind(this), true);
window.addEventListener("disable-hiding-cursor", function() { this.enable = false; }.bind(this), true);
settings.register_change_callback("no-hide-cursor", () => {
this.refresh_hide_cursor();
});
this.enable = true;
}
// Temporarily disable hiding all mouse cursors.
static enable_all()
{
window.dispatchEvent(new Event("enable-hiding-cursor"));
}
static disable_all()
{
window.dispatchEvent(new Event("disable-hiding-cursor"));
}
set enable(value)
{
if(this._enabled == value)
return;
this._enabled = value;
if(this._enabled)
this.reset_timer();
else
{
this.remove_timer();
this.show_cursor();
}
}
get enable()
{
return this._enabled;
};
remove_timer()
{
if(!this.timer)
return;
clearInterval(this.timer);
this.timer = null;
}
// Hide the cursor now, and keep it hidden very briefly even if it moves. This is done
// when releasing a zoom to prevent spuriously showing the mouse cursor.
hide_immediately(e)
{
this.force_hidden_until = Date.now() + 150;
this.idle();
}
reset_timer()
{
this.show_cursor();
this.remove_timer();
this.timer = setTimeout(this.idle, 500);
}
idle()
{
this.remove_timer();
this.hide_cursor();
}
onmousemove(e)
{
if(this.force_hidden_until && this.force_hidden_until > Date.now())
return;
this.reset_timer();
}
onblur(e)
{
this.remove_timer();
this.show_cursor();
}
show_cursor(e)
{
this.cursor_hidden = false;
this.refresh_hide_cursor();
}
hide_cursor(e)
{
this.cursor_hidden = true;
this.refresh_hide_cursor();
}
refresh_hide_cursor()
{
// Setting style.cursor to none doesn't work in Chrome. Doing it with a style works
// intermittently (seems to work better in fullscreen). Firefox doesn't have these
// problems.
// this.element.style.cursor = "none";
helpers.set_class(this.element, "hide-cursor", this.cursor_hidden && !settings.get("no-hide-cursor"));
}
}
// This handles fetching and caching image data and associated user data.
//
// We always load the user data for an illustration if it's not already loaded. We also
// load ugoira_metadata. This way, we can access all the info we need for an image in
// one place, without doing multi-phase loads elsewhere.
class image_data
{
constructor()
{
this.loaded_user_info = this.loaded_user_info.bind(this);
this.illust_modified_callbacks = new callback_list();
this.user_modified_callbacks = new callback_list();
// Cached data:
this.image_data = { };
this.user_data = { };
this.illust_loads = {};
this.user_info_loads = {};
};
// Return the singleton, creating it if needed.
static singleton()
{
if(image_data._singleton == null)
image_data._singleton = new image_data();
return image_data._singleton;
};
// Call all illust_modified callbacks.
call_user_modified_callbacks(user_id)
{
console.log("User modified:", user_id);
this.user_modified_callbacks.call(user_id);
}
call_illust_modified_callbacks(illust_id)
{
this.illust_modified_callbacks.call(illust_id);
}
// Get image data. Call callback when it's available:
//
// callback(image_data, user_data);
//
// User data for the illustration will be fetched, and returned as image_data.userInfo.
// Note that user data can change (eg. when following a user), and all images for the
// same user will share the same userInfo object.
//
// If illust_id is a video, we'll also download the metadata before returning it, and store
// it as image_data.ugoiraMetadata.
get_image_info(illust_id)
{
if(illust_id == null)
return null;
// If we already have the image data, just return it.
if(this.image_data[illust_id] != null && this.image_data[illust_id].userInfo)
{
return new Promise(resolve => {
resolve(this.image_data[illust_id]);
});
}
// If there's already a load in progress, just return it.
if(this.illust_loads[illust_id] != null)
return this.illust_loads[illust_id];
var load_promise = this.load_image_info(illust_id);
this._started_loading_image_info(illust_id, load_promise);
return load_promise;
}
_started_loading_image_info(illust_id, load_promise)
{
this.illust_loads[illust_id] = load_promise;
this.illust_loads[illust_id].then(() => {
delete this.illust_loads[illust_id];
});
}
// Like get_image_info, but return the result immediately.
//
// If the image info isn't loaded, don't start a request and just return null.
get_image_info_sync(illust_id)
{
return this.image_data[illust_id];
}
// Load illust_id and all data that it depends on.
//
// If we already have the image data (not necessarily the rest, like ugoira_metadata),
// it can be supplied with illust_data.
async load_image_info(illust_id, illust_data)
{
// We need the illust data, user data, and ugoira metadata (for illustType 2). (We could
// load manga data too, but we currently let the manga view do that.) We need to know the
// user ID and illust type to start those loads.
console.error("Fetching", illust_id);
var user_info_promise = null;
var manga_promise = null;
var ugoira_promise = null;
// Given a user ID and/or an illust_type (or null if either isn't known yet), start any
// fetches we can.
var start_loading = (user_id, illust_type, page_count) => {
// If we know the user ID and haven't started loading user info yet, start it.
if(user_info_promise == null && user_id != null)
user_info_promise = this.get_user_info(user_id);
// If we know the illust type and haven't started loading other data yet, start them.
if(page_count != null && page_count > 1 && manga_promise == null)
manga_promise = helpers.get_request("/ajax/illust/" + illust_id + "/pages", {});
if(illust_type == 2 && ugoira_promise == null)
ugoira_promise = helpers.get_request("/ajax/illust/" + illust_id + "/ugoira_meta");
};
// If we have thumbnail info, it tells us the user ID. This lets us start loading
// user info without waiting for the illustration data to finish loading first.
// Don't fetch thumbnail info if it's not already loaded.
var thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id);
if(thumbnail_info != null)
start_loading(thumbnail_info.userId, thumbnail_info.illustType, thumbnail_info.pageCount);
// If we don't have illust data, block while it loads.
if(illust_data == null)
{
var illust_result_promise = helpers.get_request("/ajax/illust/" + illust_id, {});
var illust_result = await illust_result_promise;
if(illust_result == null || illust_result.error)
return;
illust_data = illust_result.body;
}
tag_translations.get().add_translations(illust_data.tags.tags);
// Now that we have illust data, load anything we weren't able to load before.
start_loading(illust_data.userId, illust_data.illustType, illust_data.pageCount);
// Store the results.
illust_data.userInfo = await user_info_promise;
// If we're loading image info, we're almost definitely going to load the avatar, so
// start preloading it now.
helpers.preload_images([illust_data.userInfo.imageBig]);
if(manga_promise != null)
{
var manga_info = await manga_promise;
illust_data.mangaPages = manga_info.body;
}
if(ugoira_promise != null)
{
var ugoira_result = await ugoira_promise;
illust_data.ugoiraMetadata = ugoira_result.body;
}
// If this is a single-page image, create a dummy single-entry mangaPages array. This lets
// us treat all images the same.
if(illust_data.pageCount == 1)
{
illust_data.mangaPages = [{
width: illust_data.width,
height: illust_data.height,
// Rather than just referencing illust_Data.urls, copy just the image keys that
// exist in the regular mangaPages list (no thumbnails).
urls: {
original: illust_data.urls.original,
regular: illust_data.urls.regular,
small: illust_data.urls.small,
}
}];
}
// Store the image data.
this.image_data[illust_id] = illust_data;
return illust_data;
}
// The user request can either return a small subset of data (just the username,
// profile image URL, etc.), or a larger set with a webpage URL, Twitter, etc.
// User preloads often only have the smaller set, and we want to use the preload
// data whenever possible.
//
// get_user_info requests the smaller set of data, and get_user_info_full requests
// the full data.
//
// Note that get_user_info will return the full data if we have it already.
async get_user_info_full(user_id)
{
return await this._get_user_info(user_id, true);
}
async get_user_info(user_id)
{
return await this._get_user_info(user_id, false);
}
_get_user_info(user_id, load_full_data)
{
if(user_id == null)
return null;
// If we already have the user info for this illustration (and it's full data, if
// requested), we're done.
if(this.user_data[user_id] != null)
{
// user_info.partial is 1 if it's the full data (this is backwards). If we need
// full data and we only have partial data, we still need to request data.
if(!load_full_data || this.user_data[user_id].partial)
{
return new Promise(resolve => {
resolve(this.user_data[user_id]);
});
}
}
// If there's already a load in progress, just return it.
if(this.user_info_loads[user_id] != null)
return this.user_info_loads[user_id];
this.user_info_loads[user_id] = this.load_user_info(user_id);
this.user_info_loads[user_id].then(() => {
delete this.user_info_loads[user_id];
});
return this.user_info_loads[user_id];
};
async load_user_info(user_id)
{
// console.log("Fetch user", user_id);
var result = await helpers.get_request("/ajax/user/" + user_id, {full:1});
return this.loaded_user_info(result);
}
loaded_user_info(user_result)
{
if(user_result.error)
return;
var user_data = user_result.body;
var user_id = user_data.userId;
// console.log("Got user", user_id);
// Store the user data.
if(this.user_data[user_id] == null)
this.user_data[user_id] = user_data;
else
{
// If we already have an object for this user, we're probably replacing partial user data
// with full user data. Don't replace the user_data object itself, since widgets will have
// a reference to the old one which will become stale. Just replace the data inside the
// object.
var old_user_data = this.user_data[user_id];
for(var key of Object.keys(old_user_data))
delete old_user_data[key];
for(var key of Object.keys(user_data))
old_user_data[key] = user_data[key];
}
return user_data;
}
// Add image and user data to the cache that we received from other sources. Note that if
// we have any fetches in the air already, we'll leave them running.
add_illust_data(illust_data)
{
var load_promise = this.load_image_info(illust_data.illustId, illust_data);
this._started_loading_image_info(illust_data.illustId, load_promise);
}
add_user_data(user_data)
{
this.loaded_user_info({
body: user_data,
});
}
// Load bookmark tags and comments.
//
// There's no visible API to do this, so we have to scrape the bookmark_add page. I wish
// they'd just include this in bookmarkData. Since this takes an extra request, we should
// only load this if the user is viewing/editing bookmark tags.
get_bookmark_details(illust_info)
{
var illust_id = illust_info.illustId;
if(this.bookmark_details[illust_id] == null)
this.bookmark_details[illust_id] = this.load_bookmark_details(illust_info);
return this.bookmark_details[illust_id];
}
async load_bookmark_details(illust_info)
{
// Stop if this image isn't bookmarked.
if(illust_info.bookmarkData == null)
return;
// Stop if this is already loaded.
if(illust_info.bookmarkData.tags != null)
return;
var bookmark_page = await helpers.load_data_in_iframe("/bookmark_add.php?type=illust&illust_id=" + illust_info.illustId);
// Stop if the image was unbookmarked while we were loading.
if(illust_info.bookmarkData == null)
return;
var tags = bookmark_page.querySelector(".bookmark-detail-unit form input[name='tag']").value;
var comment = bookmark_page.querySelector(".bookmark-detail-unit form input[name='comment']").value;
tags = tags.split(" ");
tags = tags.filter((value) => { return value != ""; });
illust_info.bookmarkData.tags = tags;
illust_info.bookmarkData.comment = comment;
}
}
// View img fullscreen. Clicking the image will zoom it to its original size and scroll
// it around.
//
// The image is always zoomed a fixed amount from its fullscreen size. This is generally
// more usable than doing things like zooming based on the native resolution.
class on_click_viewer
{
constructor()
{
this.onresize = this.onresize.bind(this);
this.pointerdown = this.pointerdown.catch_bind(this);
this.pointerup = this.pointerup.bind(this);
this.pointermove = this.pointermove.bind(this);
this.block_event = this.block_event.bind(this);
this.window_blur = this.window_blur.bind(this);
this._zoom_levels = [null, 2, 4, 8, 1];
this._relative_zoom_level = 0;
// The caller can set this to a function to be called if the user clicks the image without
// dragging.
this.clicked_without_scrolling = null;
this.original_width = 1;
this.original_height = 1;
this.zoom_pos = [0, 0];
this._zoom_level = helpers.get_value("zoom-level", 1);
// Restore the most recent zoom mode. We assume that there's only one of these on screen.
this.locked_zoom = helpers.get_value("zoom-mode") != "normal";
this._relative_zoom_level = helpers.get_value("zoom-level-relative") || 0;
}
set_new_image(img, secondary_img, width, height)
{
if(this.img != null)
{
// Don't call this.disable, so we don't exit zoom.
this._remove_events();
this.img.remove();
}
this.img = img;
this.secondary_img = secondary_img;
this.original_width = width;
this.original_height = height;
if(this.img == null)
return;
this._add_events();
// If we've never set an image position, do it now.
if(!this.set_initial_image_position)
{
this.set_initial_image_position = true;
this.set_image_position(
[this.img.parentNode.offsetWidth * 0.5, this.img.parentNode.offsetHeight * 0.5],
[this.width * 0.5, this.height * 0.5]);
}
this.reposition();
}
block_event(e)
{
e.preventDefault();
}
enable()
{
this._add_events();
}
_add_events()
{
var target = this.img.parentNode;
this.event_target = target;
window.addEventListener("blur", this.window_blur);
window.addEventListener("resize", this.onresize, true);
target.addEventListener("pointerdown", this.pointerdown);
target.addEventListener("pointerup", this.pointerup);
target.addEventListener("dragstart", this.block_event);
target.addEventListener("selectstart", this.block_event);
target.style.userSelect = "none";
target.style.MozUserSelect = "none";
}
_remove_events()
{
if(this.event_target)
{
var target = this.event_target;
this.event_target = null;
target.removeEventListener("pointerdown", this.pointerdown);
target.removeEventListener("pointerup", this.pointerup);
target.removeEventListener("dragstart", this.block_event);
target.removeEventListener("selectstart", this.block_event);
target.style.userSelect = "none";
target.style.MozUserSelect = "";
}
window.removeEventListener("blur", this.window_blur);
window.removeEventListener("resize", this.onresize, true);
}
disable()
{
this.stop_dragging();
this._remove_events();
}
onresize(e)
{
this.reposition();
}
window_blur(e)
{
this.stop_dragging();
}
// Enable or disable zoom lock.
get locked_zoom()
{
return this._locked_zoom;
}
// Select between click-pan zooming and sticky, filled-screen zooming.
set locked_zoom(enable)
{
this._locked_zoom = enable;
helpers.set_value("zoom-mode", enable? "locked":"normal");
this.reposition();
}
get zoom_level()
{
return this._zoom_level;
}
// Set the main zoom level.
set zoom_level(value)
{
if(this._zoom_level == value)
return;
this._zoom_level = helpers.clamp(value, 0, this._zoom_levels.length - 1);
// Save the new zoom level.
helpers.set_value("zoom-level", this._zoom_level);
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 relative_zoom_level()
{
return this._relative_zoom_level;
}
set relative_zoom_level(value)
{
value = helpers.clamp(value, -8, +8);
this._relative_zoom_level = value;
helpers.set_value("zoom-level-relative", this._relative_zoom_level);
this.reposition();
}
// Return the zoom factor applied by relative zoom.
get relative_zoom_factor()
{
return Math.pow(1.5, this._relative_zoom_level);
}
// Return the active zoom ratio.
//
// This is the main and relative zooms combined.
get _effective_zoom_level()
{
if(!this.zoom_active)
return 1;
var ratio = this._zoom_levels[this._zoom_level];
// The null entry is for screen fill zooming.
if(ratio == null)
{
var screen_width = this.img.parentNode.offsetWidth;
var screen_height = this.img.parentNode.offsetHeight;
ratio = Math.max(screen_width/this.width, screen_height/this.height);
}
ratio *= this.relative_zoom_factor;
return ratio;
}
// Given a screen position, return the normalized position relative to the image.
// (0,0) is the top-left of the image and (1,1) is the bottom-right.
get_image_position(screen_pos)
{
// zoom_pos shifts the image around in screen space.
var zoom_center = [0,0];
if(this.zoom_active)
{
zoom_center[0] -= this.zoom_pos[0];
zoom_center[1] -= this.zoom_pos[1];
}
zoom_center[0] += screen_pos[0];
zoom_center[1] += screen_pos[1];
// Offset by the base screen position we're in when not zoomed (centered).
var screen_width = this.img.parentNode.offsetWidth;
var screen_height = this.img.parentNode.offsetHeight;
zoom_center[0] -= (screen_width - this.width) / 2;
zoom_center[1] -= (screen_height - this.height) / 2;
// Scale from the current zoom level to the effective size.
var zoom_level = this._effective_zoom_level;
zoom_center[0] /= zoom_level;
zoom_center[1] /= zoom_level;
return zoom_center;
}
// Given a screen position and a point on the image, align the point to the screen
// position. This has no effect when we're not zoomed.
set_image_position(screen_pos, zoom_center)
{
if(!this.zoom_active)
return;
// This just does the inverse of get_image_position.
zoom_center = [zoom_center[0], zoom_center[1]];
var zoom_level = this._effective_zoom_level;
zoom_center[0] *= zoom_level;
zoom_center[1] *= zoom_level;
// make this relative to zoom_pos, since that's what we need to set it back to below
var screen_width = this.img.parentNode.offsetWidth;
var screen_height = this.img.parentNode.offsetHeight;
zoom_center[0] += (screen_width - this.width) / 2;
zoom_center[1] += (screen_height - this.height) / 2;
zoom_center[0] -= screen_pos[0];
zoom_center[1] -= screen_pos[1];
this.zoom_pos = [-zoom_center[0], -zoom_center[1]];
this.reposition();
}
pointerdown(e)
{
if(e.button != 0)
return;
// We only want clicks on the image, or on the container backing the image, not other
// elements inside the container.
if(e.target != this.img && e.target != this.img.parentNode)
return;
this.event_target.style.cursor = "none";
// Don't show the UI if the mouse hovers over it while dragging.
document.body.classList.add("hide-ui");
if(!this._locked_zoom)
var zoom_center_percent = this.get_image_position([e.pageX, e.pageY]);
this.zoomed = true;
this.dragged_while_zoomed = false;
this.captured_pointer_id = e.pointerId;
this.img.setPointerCapture(this.captured_pointer_id);
// If this is a click-zoom, align the zoom to the point on the image that
// was clicked.
if(!this._locked_zoom)
this.set_image_position([e.pageX, e.pageY], zoom_center_percent);
this.reposition();
// Only listen to pointermove while we're dragging.
this.event_target.addEventListener("pointermove", this.pointermove);
}
pointerup(e)
{
if(e.button != 0)
return;
if(!this.zoomed)
return;
// Tell hide_mouse_cursor_on_idle that the mouse cursor should be hidden, even though the
// cursor may have just been moved. This prevents the cursor from appearing briefly and
// disappearing every time a zoom is released.
window.dispatchEvent(new Event("hide-cursor-immediately"));
this.stop_dragging();
}
stop_dragging()
{
if(this.event_target != null)
{
this.event_target.removeEventListener("pointermove", this.pointermove);
this.event_target.style.cursor = "";
}
if(this.captured_pointer_id != null)
{
this.img.releasePointerCapture(this.captured_pointer_id);
this.captured_pointer_id = null;
}
document.body.classList.remove("hide-ui");
this.zoomed = false;
this.reposition();
if(!this.dragged_while_zoomed && this.clicked_without_scrolling)
this.clicked_without_scrolling();
}
pointermove(e)
{
if(!this.zoomed)
return;
// If button 1 isn't pressed, treat this as a pointerup. (The pointer events API
// is really poorly designed in its handling of multiple button presses.)
if((e.buttons & 1) == 0)
{
this.pointerup(e);
return;
}
this.dragged_while_zoomed = true;
// Apply mouse dragging.
var x_offset = e.movementX;
var y_offset = e.movementY;
if(helpers.get_value("invert-scrolling"))
{
x_offset *= -1;
y_offset *= -1;
}
// Scale movement by the zoom level.
var zoom_level = this._effective_zoom_level;
this.zoom_pos[0] += x_offset * -1 * zoom_level;
this.zoom_pos[1] += y_offset * -1 * zoom_level;
this.reposition();
}
// Return true if zooming is active.
get zoom_active()
{
return this.zoomed || this._locked_zoom;
}
get _image_to_screen_ratio()
{
var screen_width = this.img.parentNode.offsetWidth;
var screen_height = this.img.parentNode.offsetHeight;
// 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; }
reposition()
{
if(this.img == null)
return;
// Stop if we're being called after being disabled.
if(this.img.parentNode == null)
return;
var screen_width = this.img.parentNode.offsetWidth;
var screen_height = this.img.parentNode.offsetHeight;
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 || this.img.parentNode.offsetWidth == 0 || this.img.parentNode.offsetHeight == 0)
return;
// Normally (when unzoomed), the image is centered.
var left = (screen_width - width) / 2;
var top = (screen_height - height) / 2;
if(this.zoom_active) {
// Shift by the zoom position.
left += this.zoom_pos[0];
top += this.zoom_pos[1];
// Apply the zoom.
var zoom_level = this._effective_zoom_level;
height *= zoom_level;
width *= zoom_level;
if(this._zoom_levels[this._zoom_level] == null)
{
// When we're zooming to fill the screen, clamp panning to the screen, so we always fill the
// screen and don't pan past the edge. If we're narrower than the screen, lock to center.
var orig_top = top, orig_left = left;
if(screen_height < height)
top = helpers.clamp(top, -(height - screen_height), 0); // clamp to the top and bottom
else
top = -(height - screen_height) / 2; // center vertically
if(screen_width < width)
left = helpers.clamp(left, -(width - screen_width), 0); // clamp to the left and right
else
left = -(width - screen_width) / 2; // center horizontally
// Apply any clamping we did to the position to zoom_pos too, so if you move the
// mouse far beyond the edge, you don't have to move it all the way back before we
// start panning again.
this.zoom_pos[0] += left - orig_left;
this.zoom_pos[1] += top - orig_top;
}
}
left = Math.round(left);
top = Math.round(top);
width = Math.round(width);
height = Math.round(height);
this.img.style.width = width + "px";
this.img.style.height = height + "px";
this.img.style.position = "absolute";
// We can either use CSS positioning or transforms. Transforms used to be a lot
// faster, but today it doesn't matter. However, with CSS positioning we run into
// weird Firefox compositing bugs that cause the image to disappear after zooming
// and opening the context menu. That's hard to pin down, but since it doesn't happen
// with translate, let's just use that.
// this.img.style.left = left + "px";
// this.img.style.top = top + "px";
this.img.style.transform = "translate(" + left + "px, " + top + "px)";
this.img.style.right = "auto";
this.img.style.bottom = "auto";
// If we have a secondary (preview) image, put it in the same place as the main image.
if(this.secondary_img)
{
this.secondary_img.style.width = width + "px";
this.secondary_img.style.height = height + "px";
this.secondary_img.style.position = "absolute";
this.secondary_img.style.left = left + "px";
this.secondary_img.style.top = top + "px";
this.secondary_img.style.right = "auto";
this.secondary_img.style.bottom = "auto";
}
}
}
var install_polyfills = function()
{
// Return true if name exists, eg. GM_xmlhttpRequest.
var script_global_exists = function(name)
{
// For some reason, the script globals like GM and GM_xmlhttpRequest aren't
// in window, so it's not clear how to check if they exist. Just try to
// access it and catch the ReferenceError exception if it doesn't exist.
try {
eval(name);
return true;
} catch(e) {
return false;
}
};
// If we have GM.xmlHttpRequest and not GM_xmlhttpRequest, set GM_xmlhttpRequest.
if(script_global_exists("GM") && GM.xmlHttpRequest && !script_global_exists("GM_xmlhttpRequest"))
window.GM_xmlhttpRequest = GM.xmlHttpRequest;
// padStart polyfill:
// https://github.com/uxitten/polyfill/blob/master/string.polyfill.js
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padStart
if(!String.prototype.padStart) {
String.prototype.padStart = function padStart(targetLength,padString) {
targetLength = targetLength>>0; //truncate if number or convert non-number to 0;
padString = String((typeof padString !== 'undefined' ? padString : ' '));
if (this.length > targetLength) {
return String(this);
}
else {
targetLength = targetLength-this.length;
if (targetLength > padString.length) {
padString += padString.repeat(targetLength/padString.length); //append to original to ensure we are longer than needed
}
return padString.slice(0,targetLength) + String(this);
}
};
}
// This isn't really a polyfill, but we treat it like one for convenience.
//
// When functions called from event handlers throw exceptions, GreaseMonkey usually forgets
// to log them to the console, probably sending them to some inconvenient browser-level log
// instead. Work around some of this. func.catch_bind is like func.bind, but also wraps
// the function in an exception handler to log errors correctly. The exception will still
// be raised.
//
// This is only needed in Firefox, and we just point it at bind() otherwise.
if(navigator.userAgent.indexOf("Firefox") == -1)
{
Function.prototype.catch_bind = Function.prototype.bind;
} else {
Function.prototype.catch_bind = function()
{
var func = this;
var self = arguments[0];
var bound_args = Array.prototype.slice.call(arguments, 1);
var wrapped_func = function()
{
try {
var called_args = Array.prototype.slice.call(arguments, 0);
var args = bound_args.concat(called_args);
return func.apply(self, args);
} catch(e) {
console.error(e);
throw e;
}
};
return wrapped_func;
};
}
if(!("requestFullscreen" in Element.prototype))
{
// Web API prefixing needs to be shot into the sun.
if("webkitRequestFullScreen" in Element.prototype)
{
Element.prototype.requestFullscreen = Element.prototype.webkitRequestFullScreen;
HTMLDocument.prototype.exitFullscreen = HTMLDocument.prototype.webkitCancelFullScreen;
Object.defineProperty(HTMLDocument.prototype, "fullscreenElement", {
get: function() { return this.webkitFullscreenElement; }
});
}
else if("mozRequestFullScreen" in Element.prototype)
{
Element.prototype.requestFullscreen = Element.prototype.mozRequestFullScreen;
HTMLDocument.prototype.exitFullscreen = HTMLDocument.prototype.mozCancelFullScreen;
Object.defineProperty(HTMLDocument.prototype, "fullscreenElement", {
get: function() { return this.mozFullScreenElement; }
});
}
}
// Workaround for "Violentmonkey", which is missing exportFunction:
if(!("exportFunction" in window))
{
window.exportFunction = function(func)
{
return func;
};
}
}
// A simple progress bar.
//
// Call bar.controller() to create a controller to update the progress bar.
class progress_bar
{
constructor(container)
{
this.container = container;
this.bar = this.container.appendChild(helpers.create_node('\
\
\
'));
this.bar.hidden = true;
};
// Create a progress_bar_controller for this progress bar.
//
// If there was a previous controller, it will be detached.
controller()
{
if(this.current_controller)
{
this.current_controller.detach();
this.current_controller = null;
}
this.current_controller = new progress_bar_controller(this);
return this.current_controller;
}
}
// This handles updating a progress_bar.
//
// This is separated from progress_bar, which allows us to transparently detach
// the controller from a progress_bar.
//
// For example, if we load a video file and show the loading in the progress bar, and
// the user then navigates to another video, we detach the first controller. This way,
// the new load will take over the progress bar (whether or not we actually cancel the
// earlier load) and progress bar users won't fight with each other.
class progress_bar_controller
{
constructor(bar)
{
this.progress_bar = bar;
}
set(value)
{
if(this.progress_bar == null)
return;
this.progress_bar.bar.hidden = (value == null);
this.progress_bar.bar.classList.remove("hide");
this.progress_bar.bar.getBoundingClientRect();
if(value != null)
this.progress_bar.bar.style.width = (value * 100) + "%";
}
// Flash the current progress value and fade out.
show_briefly()
{
this.progress_bar.bar.classList.add("hide");
}
detach()
{
this.progress_bar = null;
}
};
class seek_bar
{
constructor(container)
{
this.mousedown = this.mousedown.bind(this);
this.mouseup = this.mouseup.bind(this);
this.mousemove = this.mousemove.bind(this);
this.mouseover = this.mouseover.bind(this);
this.mouseout = this.mouseout.bind(this);
this.container = container;
this.bar = this.container.appendChild(helpers.create_node('\
\
'));
this.bar.addEventListener("mousedown", this.mousedown);
this.bar.addEventListener("mouseover", this.mouseover);
this.bar.addEventListener("mouseout", this.mouseout);
this.current_time = 0;
this.duration = 1;
this.refresh_visibility();
this.refresh();
this.set_callback(null);
};
mousedown(e)
{
// Never start dragging while we have no callback. This generally shouldn't happen
// since we should be hidden.
if(this.callback == null)
return;
if(this.dragging)
return;
console.log("down");
this.dragging = true;
helpers.set_class(this.bar, "dragging", this.dragging);
this.refresh_visibility();
// Only listen to mousemove while we're dragging. Put this on window, so we get drags outside
// the window.
window.addEventListener("mousemove", this.mousemove);
window.addEventListener("mouseup", this.mouseup);
this.set_drag_pos(e);
}
mouseover()
{
this.hovering = true;
this.refresh_visibility();
}
mouseout()
{
this.hovering = false;
this.refresh_visibility();
}
refresh_visibility()
{
// Show the seek bar if the mouse is over it, or if we're actively dragging.
// Only show if we're active.
var visible = this.callback != null && (this.hovering || this.dragging);
helpers.set_class(this.bar, "visible", visible);
}
stop_dragging()
{
if(!this.dragging)
return;
this.dragging = false;
helpers.set_class(this.bar, "dragging", this.dragging);
this.refresh_visibility();
window.removeEventListener("mousemove", this.mousemove);
window.removeEventListener("mouseup", this.mouseup);
if(this.callback)
this.callback(false, null);
}
mouseup(e)
{
this.stop_dragging();
}
mousemove(e)
{
this.set_drag_pos(e);
}
// The user clicked or dragged. Pause and seek to the clicked position.
set_drag_pos(e)
{
// Get the mouse position relative to the seek bar.
var bounds = this.bar.getBoundingClientRect();
var pos = (e.clientX - bounds.left) / bounds.width;
pos = Math.max(0, Math.min(1, pos));
var time = pos * this.duration;
// Tell the user to seek.
this.callback(true, time);
}
// Set the callback. callback(pause, time) will be called when the user interacts
// with the seek bar. The first argument is true if the video should pause (because
// the user is dragging the seek bar), and time is the desired playback time. If callback
// is null, remove the callback.
set_callback(callback)
{
this.bar.hidden = callback == null;
if(this.callback == callback)
return;
// Stop dragging on any previous caller before we replace the callback.
if(this.callback != null)
this.stop_dragging();
this.callback = callback;
this.refresh_visibility();
};
set_duration(seconds)
{
this.duration = seconds;
this.refresh();
};
set_current_time(seconds)
{
this.current_time = seconds;
this.refresh();
};
refresh()
{
var position = this.duration > 0.0001? (this.current_time / this.duration):0;
this.bar.querySelector(".seek-fill").style.width = (position * 100) + "%";
};
}
// https://github.com/lyngklip/structjs/blob/master/struct.js
// The MIT License (MIT)
// Copyright (c) 2016 Aksel Jensen (TheRealAksel at github)
// This is completely unreadable. Why would anyone write JS like this?
/*eslint-env es6, node*/
struct = (function() {
const rechk = /^([<>])?(([1-9]\d*)?([xcbB?hHiIfdsp]))*$/
const refmt = /([1-9]\d*)?([xcbB?hHiIfdsp])/g
const str = (v,o,c) => String.fromCharCode(
...new Uint8Array(v.buffer, v.byteOffset + o, c))
const rts = (v,o,c,s) => new Uint8Array(v.buffer, v.byteOffset + o, c)
.set(s.split('').map(str => str.charCodeAt(0)))
const pst = (v,o,c) => str(v, o + 1, Math.min(v.getUint8(o), c - 1))
const tsp = (v,o,c,s) => { v.setUint8(o, s.length); rts(v, o + 1, c - 1, s) }
const lut = le => ({
x: c=>[1,c,0],
c: c=>[c,1,o=>({u:v=>str(v, o, 1) , p:(v,c)=>rts(v, o, 1, c) })],
'?': c=>[c,1,o=>({u:v=>Boolean(v.getUint8(o)),p:(v,B)=>v.setUint8(o,B)})],
b: c=>[c,1,o=>({u:v=>v.getInt8( o ), p:(v,b)=>v.setInt8( o,b )})],
B: c=>[c,1,o=>({u:v=>v.getUint8( o ), p:(v,B)=>v.setUint8( o,B )})],
h: c=>[c,2,o=>({u:v=>v.getInt16( o,le), p:(v,h)=>v.setInt16( o,h,le)})],
H: c=>[c,2,o=>({u:v=>v.getUint16( o,le), p:(v,H)=>v.setUint16( o,H,le)})],
i: c=>[c,4,o=>({u:v=>v.getInt32( o,le), p:(v,i)=>v.setInt32( o,i,le)})],
I: c=>[c,4,o=>({u:v=>v.getUint32( o,le), p:(v,I)=>v.setUint32( o,I,le)})],
f: c=>[c,4,o=>({u:v=>v.getFloat32(o,le), p:(v,f)=>v.setFloat32(o,f,le)})],
d: c=>[c,8,o=>({u:v=>v.getFloat64(o,le), p:(v,d)=>v.setFloat64(o,d,le)})],
s: c=>[1,c,o=>({u:v=>str(v,o,c), p:(v,s)=>rts(v,o,c,s.slice(0,c ) )})],
p: c=>[1,c,o=>({u:v=>pst(v,o,c), p:(v,s)=>tsp(v,o,c,s.slice(0,c - 1) )})]
})
const errbuf = new RangeError("Structure larger than remaining buffer")
const errval = new RangeError("Not enough values for structure")
const struct = format => {
let fns = [], size = 0, m = rechk.exec(format)
if (!m) { throw new RangeError("Invalid format string") }
const t = lut('<' === m[1]), lu = (n, c) => t[c](n ? parseInt(n, 10) : 1)
while ((m = refmt.exec(format))) { ((r, s, f) => {
for (let i = 0; i < r; ++i, size += s) { if (f) {fns.push(f(size))} }
})(...lu(...m.slice(1)))}
const unpack_from = (arrb, offs) => {
if (arrb.byteLength < (offs|0) + size) { throw errbuf }
let v = new DataView(arrb, offs|0)
return fns.map(f => f.u(v))
}
const pack_into = (arrb, offs, ...values) => {
if (values.length < fns.length) { throw errval }
if (arrb.byteLength < offs + size) { throw errbuf }
const v = new DataView(arrb, offs)
new Uint8Array(arrb, offs, size).fill(0)
fns.forEach((f, i) => f.p(v, values[i]))
}
const pack = (...values) => {
let b = new ArrayBuffer(size)
pack_into(b, 0, ...values)
return b
}
const unpack = arrb => unpack_from(arrb, 0)
function* iter_unpack(arrb) {
for (let offs = 0; offs + size <= arrb.byteLength; offs += size) {
yield unpack_from(arrb, offs);
}
}
return Object.freeze({
unpack, pack, unpack_from, pack_into, iter_unpack, format, size})
}
return struct;
})();
/*
const pack = (format, ...values) => struct(format).pack(...values)
const unpack = (format, buffer) => struct(format).unpack(buffer)
const pack_into = (format, arrb, offs, ...values) =>
struct(format).pack_into(arrb, offs, ...values)
const unpack_from = (format, arrb, offset) =>
struct(format).unpack_from(arrb, offset)
const iter_unpack = (format, arrb) => struct(format).iter_unpack(arrb)
const calcsize = format => struct(format).size
module.exports = {
struct, pack, unpack, pack_into, unpack_from, iter_unpack, calcsize }
*/
// Encode a Pixiv video to MJPEG, using an MKV container.
//
// Other than having to wrangle the MKV format, this is easy: the source files appear to always
// be JPEGs, so we don't need to do any conversions and the encoding is completely lossless (other
// than the loss Pixiv forces by reencoding everything to JPEG). The result is standard and plays
// in eg. VLC, but it's not a WebM file and browsers don't support it.
var ugoira_downloader_mjpeg = function(illust_data, progress)
{
this.illust_data = illust_data;
this.progress = progress;
// We don't need image data, but we make a dummy canvas to make ZipImagePlayer happy.
var canvas = document.createElement("canvas");
// Create a ZipImagePlayer. This will download the ZIP, and handle parsing the file.
this.player = new ZipImagePlayer({
"metadata": illust_data.ugoiraMetadata,
"source": illust_data.ugoiraMetadata.originalSrc,
"mime_type": illust_data.ugoiraMetadata.mime_type,
"canvas": canvas,
"progress": this.zip_finished_loading.bind(this),
});
}
ugoira_downloader_mjpeg.prototype.zip_finished_loading = function(progress)
{
if(this.progress)
{
try {
this.progress.set(progress);
} catch(e) {
console.error(e);
}
}
// We just want to know when the ZIP has been completely downloaded, which is indicated when progress
// finishes.
if(progress != null)
return;
// Some posts have the wrong dimensions in illust_data (63162632). If we use it, the resulting
// file won't play. Decode the first image to find the real resolution.
var img = document.createElement("img");
var blob = new Blob([this.player.getFrameData(0)], {type: this.player.op.metadata.mime_type || "image/png"});
var first_frame_url = URL.createObjectURL(blob);
img.src = first_frame_url;
img.onload = (e) =>
{
URL.revokeObjectURL(first_frame_url);
this.continue_saving(img.naturalWidth, img.naturalHeight)
};
}
ugoira_downloader_mjpeg.prototype.continue_saving = function(width, height)
{
try {
var encoder = new encode_mkv(width, height);
// Add each frame to the encoder.
var frame_count = this.illust_data.ugoiraMetadata.frames.length;
for(var frame = 0; frame < frame_count; ++frame)
{
var frame_data = this.player.getFrameData(frame);
encoder.add(frame_data, this.player.getFrameNoDuration(frame));
};
// There's no way to encode the duration of the final frame of an MKV, which means the last frame
// will be effectively lost when looping. In theory the duration field on the file should tell the
// player this, but at least VLC doesn't do that.
//
// Work around this by repeating the last frame with a zero duration.
//
// In theory we could set the "invisible" bit on this frame ("decoded but not displayed"), but that
// doesn't seem to be used, at least not by VLC.
var frame_data = this.player.getFrameData(frame_count-1);
encoder.add(frame_data, 0);
// Build the file.
var mkv = encoder.build();
var filename = this.illust_data.userInfo.name + " - " + this.illust_data.illustId + " - " + this.illust_data.illustTitle + ".mkv";
helpers.save_blob(mkv, filename);
} catch(e) {
console.error(e);
};
};
// This is the base class for viewer classes, which are used to view a particular
// type of content in the main display.
class viewer
{
constructor(container, illust_data)
{
this.illust_data = illust_data;
}
// Remove any event listeners, nodes, etc. and shut down so a different viewer can
// be used.
shutdown() { }
set page(page) { }
get page() { return 0; }
// Return the file type for display in the UI, eg. "PNG".
get current_image_type() { return null; }
// If an image is displayed, clear it.
//
// This is only used with the illust viewer when changing manga pages in cases
// where we don't want the old image to be displayed while the new one loads.
set hide_image(value) { }
get hide_image() { return false; }
}
// This is the viewer for static images. We take an illust_data and show
// either a single image or navigate between an image sequence.
class viewer_images extends viewer
{
constructor(container, illust_data, options)
{
super(container, illust_data);
this.container = container;
this.options = options || {};
this.manga_page_bar = options.manga_page_bar;
this.onkeydown = this.onkeydown.bind(this);
this.blank_image = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
this.index = options.manga_page || 0;
// Create a click and drag viewer for the image.
this.on_click_viewer = new on_click_viewer();
main_context_menu.get.on_click_viewer = this.on_click_viewer;
// Make a list of image URLs we're viewing.
this.images = [];
// If there are multiple pages, get image info from mangaPages. Otherwise, use
// the main image.
for(var page of illust_data.mangaPages)
{
this.images.push({
url: page.urls.original,
preview_url: page.urls.small,
width: page.width,
height: page.height,
});
}
this.refresh();
}
get current_image_type()
{
var url;
if(this.illust_data.illustType != 2 && this.illust_data.pageCount == 1)
url = this.illust_data.urls.original;
else
url = this.img.src;
return helpers.get_extension(url).toUpperCase();
}
shutdown()
{
if(this.on_click_viewer)
{
this.on_click_viewer.disable();
this.on_click_viewer = null;
}
if(this.img.parentNode)
this.img.remove();
if(this.preview_img)
this.preview_img.remove();
main_context_menu.get.on_click_viewer = null;
}
get page()
{
return this.index;
}
set page(page)
{
this.index = page;
this.refresh();
}
refresh()
{
var current_image = this.images[this.index];
if(current_image == null)
{
log.error("Invalid page", this.index, "in images", this.images);
return;
}
if(this.on_click_viewer && this.img && this.img.src == current_image.url)
return;
// Create the new image and pass it to the viewer.
this._create_image(current_image.url, current_image.preview_url, current_image.width, current_image.height);
// Decode the next and previous image. This reduces flicker when changing pages
// since the image will already be decoded.
if(this.index > 0)
helpers.decode_image(this.images[this.index - 1].url);
if(this.index + 1 < this.images.length)
helpers.decode_image(this.images[this.index + 1].url);
// If we have a manga_page_bar, update to show the current page.
if(this.manga_page_bar)
{
if(this.images.length == 1)
this.manga_page_bar.set(null);
else
this.manga_page_bar.set((this.index+1) / this.images.length);
}
}
_create_image(url, preview_url, width, height)
{
if(this.img)
{
this.img.remove();
this.img = null;
}
if(this.preview_img)
{
this.preview_img.remove();
this.preview_img = null;
}
// Create the low-res preview. This loads the thumbnail underneath the main image. Don't set the
// "filtering" class, since using point sampling for the thumbnail doesn't make sense.
this.preview_img = document.createElement("img");
this.preview_img.src = preview_url;
this.preview_img.classList.add("low-res-preview");
// The secondary image holds the low-res preview image that's shown underneath the loading image.
// It just follows the main image around and shouldn't receive input events.
this.preview_img.style.pointerEvents = "none";
this.container.appendChild(this.preview_img);
this.img = document.createElement("img");
this.img.src = url;
this.img.className = "filtering";
this.container.appendChild(this.img);
// When the image finishes loading, remove the preview image, to prevent artifacts with
// transparent images. Keep a reference to preview_img, so we don't need to worry about
// it changing. on_click_viewer will still have a reference to it, but it won't do anything.
var preview_image = this.preview_img;
this.img.addEventListener("load", (e) => {
preview_image.remove();
});
this.on_click_viewer.set_new_image(this.img, this.preview_img, width, height);
}
onkeydown(e)
{
if(e.ctrlKey || e.altKey || e.metaKey)
return;
switch(e.keyCode)
{
case 36: // home
e.stopPropagation();
e.preventDefault();
main_controller.singleton.show_illust(this.illust_data.id, {
manga_page: 0,
});
return;
case 35: // end
e.stopPropagation();
e.preventDefault();
main_controller.singleton.show_illust(this.illust_data.id, {
manga_page: this.illust_data.pageCount - 1,
});
return;
}
}
}
// This is used to display a muted image.
class viewer_muted extends viewer
{
constructor(container, illust_data)
{
super(container, illust_data);
this.container = container;
// Create the display.
this.root = helpers.create_from_template(".template-muted");
container.appendChild(this.root);
// Show the user's avatar instead of the muted image.
var img = this.root.querySelector(".muted-image");
img.src = illust_data.userInfo.imageBig;
var muted_tag = muting.singleton.any_tag_muted(illust_data.tags.tags);
var muted_user = muting.singleton.is_muted_user_id(illust_data.userId);
var muted_label = this.root.querySelector(".muted-label");
if(muted_tag)
muted_label.innerText = muted_tag;
else
muted_label.innerText = illust_data.userInfo.name;
}
shutdown()
{
this.root.parentNode.removeChild(this.root);
}
}
class viewer_ugoira extends viewer
{
constructor(container, illust_data, seek_bar, options)
{
super(container, illust_data);
console.log("create player:", illust_data.illustId);
this.refresh_focus = this.refresh_focus.bind(this);
this.clicked_canvas = this.clicked_canvas.bind(this);
this.onkeydown = this.onkeydown.bind(this);
this.drew_frame = this.drew_frame.bind(this);
this.progress = this.progress.bind(this);
this.seek_callback = this.seek_callback.bind(this);
this.container = container;
this.options = options;
this.seek_bar = seek_bar;
// Create an image to display the static image while we load.
//
// Like static image viewing, load the thumbnail, then the main image on top, since
// the thumbnail will often be visible immediately.
this.preview_img1 = document.createElement("img");
this.preview_img1.classList.add("low-res-preview");
this.preview_img1.style.position = "absolute";
this.preview_img1.style.width = "100%";
this.preview_img1.style.height = "100%";
this.preview_img1.style.objectFit = "contain";
this.preview_img1.src = illust_data.urls.small;
this.container.appendChild(this.preview_img1);
this.preview_img2 = document.createElement("img");
this.preview_img2.style.position = "absolute";
this.preview_img2.className = "filtering";
this.preview_img2.style.width = "100%";
this.preview_img2.style.height = "100%";
this.preview_img2.style.objectFit = "contain";
this.preview_img2.src = illust_data.urls.original;
this.container.appendChild(this.preview_img2);
// Remove the low-res preview image when the high-res one finishes loading.
this.preview_img2.addEventListener("load", (e) => {
this.preview_img1.remove();
});
// Create a canvas to render into.
this.canvas = document.createElement("canvas");
this.canvas.hidden = true;
this.canvas.className = "filtering";
this.canvas.style.width = "100%";
this.canvas.style.height = "100%";
this.canvas.style.objectFit = "contain";
this.container.appendChild(this.canvas);
this.canvas.addEventListener("click", this.clicked_canvas, false);
// True if we want to play if the window has focus. We always pause when backgrounded.
this.want_playing = true;
// True if the user is seeking. We temporarily pause while seeking. This is separate
// from this.want_playing so we stay paused after seeking if we were paused at the start.
this.seeking = false;
window.addEventListener("visibilitychange", this.refresh_focus);
// Create the player.
this.player = new ZipImagePlayer({
"metadata": illust_data.ugoiraMetadata,
"autoStart": false,
"source": illust_data.ugoiraMetadata.originalSrc,
"mime_type": illust_data.ugoiraMetadata.mime_type,
"autosize": true,
"canvas": this.canvas,
"loop": true,
"debug": false,
"progress": this.progress,
drew_frame: this.drew_frame,
});
this.refresh_focus();
}
progress(value)
{
if(this.options.progress_bar)
this.options.progress_bar.set(value);
if(value == null)
{
// Once we send "finished", don't make any more progress calls.
this.options.progress_bar = null;
// Enable the seek bar once loading finishes.
if(this.seek_bar)
this.seek_bar.set_callback(this.seek_callback);
}
}
// Once we draw a frame, hide the preview and show the canvas. This avoids
// flicker when the first frame is drawn.
drew_frame()
{
this.preview_img1.hidden = true;
this.preview_img2.hidden = true;
this.canvas.hidden = false;
if(this.seek_bar)
{
// Update the seek bar.
var frame_time = this.player.getCurrentFrameTime();
this.seek_bar.set_current_time(this.player.getCurrentFrameTime());
this.seek_bar.set_duration(this.player.getTotalDuration());
}
}
// This is sent manually by the UI handler so we can control focus better.
onkeydown(e)
{
if(e.keyCode >= 49 && e.keyCode <= 57)
{
// 5 sets the speed to default, 1234 slow the video down, and 6789 speed it up.
e.stopPropagation();
e.preventDefault();
if(!this.player)
return;
var speed;
switch(e.keyCode)
{
case 49: speed = 0.10; break; // 1
case 50: speed = 0.25; break; // 2
case 51: speed = 0.50; break; // 3
case 52: speed = 0.75; break; // 4
case 53: speed = 1.00; break; // 5
case 54: speed = 1.25; break; // 6
case 55: speed = 1.50; break; // 7
case 56: speed = 1.75; break; // 8
case 57: speed = 2.00; break; // 9
}
this.player.setSpeed(speed);
return;
}
switch(e.keyCode)
{
case 32: // space
e.stopPropagation();
e.preventDefault();
if(this.player)
this.player.togglePause();
return;
case 36: // home
e.stopPropagation();
e.preventDefault();
if(!this.player)
return;
this.player.rewind();
return;
case 35: // end
e.stopPropagation();
e.preventDefault();
if(!this.player)
return;
this.pause();
this.player.setCurrentFrame(this.player.getFrameCount() - 1);
return;
case 81: // q
case 87: // w
e.stopPropagation();
e.preventDefault();
if(!this.player)
return;
this.pause();
var total_frames = this.player.getFrameCount();
var current_frame = this.player.getCurrentFrame();
var next = e.keyCode == 87;
var new_frame = current_frame + (next?+1:-1);
this.player.setCurrentFrame(new_frame);
return;
}
}
play()
{
this.want_playing = true;
this.refresh_focus();
}
pause()
{
this.want_playing = false;
this.refresh_focus();
}
shutdown()
{
console.log("shutdown player:", this.illust_data.illustId);
this.finished = true;
if(this.seek_bar)
{
this.seek_bar.set_callback(null);
this.seek_bar = null;
}
window.removeEventListener("visibilitychange", this.refresh_focus);
// Send a finished progress callback if we were still loading. We won't
// send any progress calls after this (though the ZipImagePlayer will finish
// downloading the file anyway).
this.progress(null);
if(this.player)
this.player.pause();
this.preview_img1.remove();
this.preview_img2.remove();
this.canvas.remove();
}
refresh_focus()
{
if(this.player == null)
return;
var active = this.want_playing && !this.seeking && !window.document.hidden && !this._hidden;
if(active)
this.player.play();
else
this.player.pause();
};
clicked_canvas(e)
{
this.want_playing = !this.want_playing;
this.refresh_focus();
}
// This is called when the user interacts with the seek bar.
seek_callback(pause, seconds)
{
this.seeking = pause;
this.refresh_focus();
if(seconds != null)
this.player.setCurrentFrameTime(seconds);
};
}
/*
* The MIT License (MIT)
*
* Copyright (c) 2014 Pixiv Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
function ZipImagePlayer(options) {
this.op = options;
if (!Blob) {
this._error("No Blob support");
}
if (!Uint8Array) {
this._error("No Uint8Array support");
}
if (!DataView) {
this._error("No DataView support");
}
if (!ArrayBuffer) {
this._error("No ArrayBuffer support");
}
this._loadingState = 0;
this._dead = false;
this._context = options.canvas.getContext("2d");
this._files = {};
this._frameCount = this.op.metadata.frames.length;
this._debugLog("Frame count: " + this._frameCount);
this._frame = 0;
this._loadFrame = 0;
// Make a list of timestamps for each frame.
this._frameTimestamps = [];
var milliseconds = 0;
for(var frame of this.op.metadata.frames)
{
this._frameTimestamps.push(milliseconds);
milliseconds += frame.delay;
}
this._frameImages = [];
this._paused = false;
this._startLoad();
this.speed = 1;
if (this.op.autoStart) {
this.play();
} else {
this._paused = true;
}
}
// Removed partial loading. It doesn't cache in Firefox, and it's unnecessary with the very
// tiny files Pixiv supports.
ZipImagePlayer.prototype = {
_failed: false,
_mkerr: function(msg) {
var _this = this;
return function() {
_this._error(msg);
}
},
_error: function(msg) {
this._failed = true;
throw Error("ZipImagePlayer error: " + msg);
},
_debugLog: function(msg) {
if (this.op.debug) {
console.log(msg);
}
},
async _load() {
var _this = this;
// Use helpers.fetch_resource, so we share fetches with preloading.
var response = helpers.fetch_resource(this.op.source, {
onprogress: function(e) {
if(!this.op.progress)
return;
try {
this.op.progress(e.loaded / e.total);
} catch(e) {
console.error(e);
}
}.bind(this),
});
var response = await response;
if (_this._dead) {
return;
}
_this._buf = response;
var length = _this._buf.byteLength;
_this._len = length;
_this._pHead = length;
_this._bytes = new Uint8Array(_this._buf);
this._findCentralDirectory();
if(this.op.progress)
{
try {
setTimeout(function() {
this.op.progress(null);
}.bind(this), 0);
} catch(e) {
console.error(e);
}
}
},
_startLoad: function() {
var _this = this;
if (!this.op.source) {
// Unpacked mode (individiual frame URLs) - just load the frames.
this._loadNextFrame();
return;
}
_this._load();
},
_findCentralDirectory: function() {
// No support for ZIP file comment
var dv = new DataView(this._buf, this._len - 22, 22);
if (dv.getUint32(0, true) != 0x06054b50) {
this._error("End of Central Directory signature not found");
}
var count = dv.getUint16(10, true);
var size = dv.getUint32(12, true);
var offset = dv.getUint32(16, true);
if (offset < this._pTail) {
this._error("End central directory past end of file");
return;
}
// Parse the central directory.
var dv = new DataView(this._buf, offset, size);
var p = 0;
for (var i = 0; i < count; i++ ) {
if (dv.getUint32(p, true) != 0x02014b50) {
this._error("Invalid Central Directory signature");
}
var compMethod = dv.getUint16(p + 10, true);
var uncompSize = dv.getUint32(p + 24, true);
var nameLen = dv.getUint16(p + 28, true);
var extraLen = dv.getUint16(p + 30, true);
var cmtLen = dv.getUint16(p + 32, true);
var off = dv.getUint32(p + 42, true);
if (compMethod != 0) {
this._error("Unsupported compression method");
}
p += 46;
var nameView = new Uint8Array(this._buf, offset + p, nameLen);
var name = "";
for (var j = 0; j < nameLen; j++) {
name += String.fromCharCode(nameView[j]);
}
p += nameLen + extraLen + cmtLen;
/*this._debugLog("File: " + name + " (" + uncompSize +
" bytes @ " + off + ")");*/
this._files[name] = {off: off, len: uncompSize};
}
// Two outstanding fetches at any given time.
// Note: the implementation does not support more than two.
if (this._pHead < this._pTail) {
this._error("Chunk past end of file");
return;
}
this._pHead = this._len;
this._loadNextFrame();
},
_fileDataStart: function(offset) {
var dv = new DataView(this._buf, offset, 30);
var nameLen = dv.getUint16(26, true);
var extraLen = dv.getUint16(28, true);
return offset + 30 + nameLen + extraLen;
},
_isFileAvailable: function(name) {
var info = this._files[name];
if (!info) {
this._error("File " + name + " not found in ZIP");
}
if (this._pHead < (info.off + 30)) {
return false;
}
return this._pHead >= (this._fileDataStart(info.off) + info.len);
},
getFrameData: function(frame) {
if (this._dead) {
return;
}
if (frame >= this._frameCount) {
return null;
}
var meta = this.op.metadata.frames[frame];
if (!this._isFileAvailable(meta.file)) {
return null;
}
var off = this._fileDataStart(this._files[meta.file].off);
var end = off + this._files[meta.file].len;
var mime_type = this.op.metadata.mime_type || "image/png";
var slice;
if (!this._buf.slice) {
slice = new ArrayBuffer(this._files[meta.file].len);
var view = new Uint8Array(slice);
view.set(this._bytes.subarray(off, end));
} else {
slice = this._buf.slice(off, end);
}
return slice;
},
_loadNextFrame: function() {
if (this._dead) {
return;
}
var frame = this._loadFrame;
if (frame >= this._frameCount) {
return;
}
var meta = this.op.metadata.frames[frame];
if (!this.op.source) {
// Unpacked mode (individiual frame URLs)
this._loadFrame += 1;
this._loadImage(frame, meta.file, false);
return;
}
if (!this._isFileAvailable(meta.file)) {
return;
}
this._loadFrame += 1;
var off = this._fileDataStart(this._files[meta.file].off);
var end = off + this._files[meta.file].len;
var mime_type = this.op.metadata.mime_type || "image/png";
var slice = this._buf.slice(off, end);
var blob = new Blob([slice], {type: mime_type});
/*_this._debugLog("Loading " + meta.file + " to frame " + frame);*/
var url = URL.createObjectURL(blob);
this._loadImage(frame, url, true);
},
_loadImage: function(frame, url, isBlob) {
var _this = this;
var image = document.createElement("img");
// "can't access dead object"
var meta = this.op.metadata.frames[frame];
image.addEventListener('load', function() {
_this._debugLog("Loaded " + meta.file + " to frame " + frame);
if (isBlob) {
URL.revokeObjectURL(url);
}
if (_this._dead) {
return;
}
_this._frameImages[frame] = image;
if (_this._loadingState == 0) {
_this._displayFrame.apply(_this);
}
if (frame >= (_this._frameCount - 1)) {
_this._setLoadingState(2);
_this._buf = null;
_this._bytes = null;
} else {
_this._loadNextFrame();
}
});
image.src = url;
},
_setLoadingState: function(state) {
if (this._loadingState != state) {
this._loadingState = state;
}
},
_displayFrame: function() {
if (this._dead) {
return;
}
var _this = this;
var meta = this.op.metadata.frames[this._frame];
// this._debugLog("Displaying frame: " + this._frame + " " + meta.file);
var image = this._frameImages[this._frame];
if (!image) {
this._debugLog("Image not available!");
this._setLoadingState(0);
return;
}
if (this._loadingState != 2) {
this._setLoadingState(1);
}
if (this.op.autosize) {
if (this._context.canvas.width != image.width || this._context.canvas.height != image.height) {
// make the canvas autosize itself according to the images drawn on it
// should set it once, since we don't have variable sized frames
this._context.canvas.width = image.width;
this._context.canvas.height = image.height;
}
};
this.drawn_frame = this._frame;
this._context.clearRect(0, 0, this.op.canvas.width,
this.op.canvas.height);
this._context.drawImage(image, 0, 0);
// If the user wants to know when the frame is ready, call it.
if(this.op.drew_frame)
{
try {
setTimeout(function() {
this.op.drew_frame(null);
}.bind(this), 0);
} catch(e) {
console.error(e);
}
}
if (this._paused)
return;
this._pending_frame_metadata = meta;
this._refreshTimer();
},
_unsetTimer: function() {
if(!this._timer)
return;
clearTimeout(this._timer);
this._timer = null;
},
_refreshTimer: function() {
if(this._paused)
return;
this._unsetTimer();
this._timer = setTimeout(this._nextFrame.bind(this), this._pending_frame_metadata.delay / this.speed);
},
getFrameDuration: function() {
var meta = this.op.metadata.frames[this._frame];
return meta.delay;
},
getFrameNoDuration: function(frame) {
var meta = this.op.metadata.frames[frame];
return meta.delay;
},
_nextFrame: function(frame) {
this._timer = null;
if (this._frame >= (this._frameCount - 1)) {
if (this.op.loop) {
this._frame = 0;
} else {
this.pause();
return;
}
} else {
this._frame += 1;
}
this._displayFrame();
},
play: function() {
if (this._dead) {
return;
}
if (this._paused) {
this._paused = false;
this._displayFrame();
}
},
pause: function() {
if (this._dead) {
return;
}
if (!this._paused) {
this._unsetTimer();
this._paused = true;
}
},
togglePause: function() {
if(this._paused)
this.play();
else
this.pause();
},
rewind: function() {
if (this._dead) {
return;
}
this._frame = 0;
this._unsetTimer();
this._displayFrame();
},
setSpeed: function(value) {
this.speed = value;
// Refresh the timer, so we don't wait a long time if we're changing from a very slow
// playback speed.
this._refreshTimer();
},
stop: function() {
this._debugLog("Stopped");
this._dead = true;
this._unsetTimer();
this._frameImages = null;
this._buf = null;
this._bytes = null;
},
getCurrentFrame: function() {
return this._frame;
},
setCurrentFrame: function(frame) {
frame %= this._frameCount;
if(frame < 0)
frame += this._frameCount;
this._frame = frame;
this._displayFrame();
},
getTotalDuration: function() {
var last_frame = this.op.metadata.frames.length - 1;
return this._frameTimestamps[last_frame] / 1000;
},
getCurrentFrameTime: function() {
return this._frameTimestamps[this._frame] / 1000;
},
// Set the video to the closest frame to the given time.
setCurrentFrameTime: function(seconds) {
// We don't actually need to check all frames, but there's no need to optimize this.
var closest_frame = null;
var closest_error = null;
for(var frame = 0; frame < this.op.metadata.frames.length; ++frame)
{
var error = Math.abs(seconds - this._frameTimestamps[frame]/1000);
if(closest_frame == null || error < closest_error)
{
closest_frame = frame;
closest_error = error;
}
}
this._frame = closest_frame;
this._displayFrame();
},
getLoadedFrames: function() {
return this._frameImages.length;
},
getFrameCount: function() {
return this._frameCount;
},
hasError: function() {
return this._failed;
}
}
// The base class for our main views.
class view
{
constructor(container)
{
this.container = container;
// Make our container focusable, so we can give it keyboard focus when we
// become active.
this.container.tabIndex = -1;
}
// Handle a key input. This is only called while the view is active.
handle_onkeydown(e)
{
}
// If this view is displaying an image, return its ID. Otherwise, return null.
get displayed_illust_id()
{
return null;
}
// If this view is displaying a manga page, return its ID. Otherwise, return null.
// If this is non-null, displayed_illust_id will always also be non-null.
get displayed_illust_page()
{
return null;
}
// These are called to restore the scroll position on navigation.
scroll_to_top() { }
restore_scroll_position() { }
scroll_to_illust_id(illust_id, manga_page) { }
set active(active)
{
// Show or hide the view container.
this.container.hidden = !active;
if(active)
{
// Focus the container, so it receives keyboard events, eg. home/end.
this.container.focus();
}
else
{
// When the view isn't active, send viewhidden to close all popup menus inside it.
view_hidden_listener.send_viewhidden(this.container);
}
}
}
// The main UI. This handles creating the viewers and the global UI.
class view_illust extends view
{
constructor(container)
{
super(container);
if(debug_show_ui) document.body.classList.add("force-ui");
this.onwheel = this.onwheel.bind(this);
this.refresh_ui = this.refresh_ui.bind(this);
this.data_source_updated = this.data_source_updated.bind(this);
this.current_illust_id = -1;
this.latest_navigation_direction_down = true;
this.container = container;
this.progress_bar = main_controller.singleton.progress_bar;
// Create a UI box and put it in its container.
var ui_container = this.container.querySelector(".ui");
this.ui = new image_ui(ui_container, this.progress_bar);
var ui_box = this.container.querySelector(".ui-box");
var ui_visibility_changed = () => {
// Hide the dropdown tag widget when the hover UI is hidden.
if(!ui_box.classList.contains("hovering-over-box") && !ui_box.classList.contains("hovering-over-sphere"))
{
this.ui.bookmark_tag_widget.visible = false; // XXX remove
view_hidden_listener.send_viewhidden(ui_box);
}
};
ui_box.addEventListener("mouseenter", (e) => { helpers.set_class(ui_box, "hovering-over-box", true); ui_visibility_changed(); });
ui_box.addEventListener("mouseleave", (e) => { helpers.set_class(ui_box, "hovering-over-box", false); ui_visibility_changed(); });
var hover_circle = this.container.querySelector(".ui .hover-circle");
hover_circle.addEventListener("mouseenter", (e) => { helpers.set_class(ui_box, "hovering-over-sphere", true); ui_visibility_changed(); });
hover_circle.addEventListener("mouseleave", (e) => { helpers.set_class(ui_box, "hovering-over-sphere", false); ui_visibility_changed(); });
image_data.singleton().user_modified_callbacks.register(this.refresh_ui);
image_data.singleton().illust_modified_callbacks.register(this.refresh_ui);
settings.register_change_callback("recent-bookmark-tags", this.refresh_ui);
new hide_mouse_cursor_on_idle(this.container.querySelector(".image-container"));
// this.manga_thumbnails = new manga_thumbnail_widget(this.container.querySelector(".manga-thumbnail-container"));
this.container.addEventListener("wheel", this.onwheel, { passive: false });
// A bar showing how far along in an image sequence we are:
this.manga_page_bar = new progress_bar(this.container.querySelector(".ui-box")).controller();
this.seek_bar = new seek_bar(this.container.querySelector(".ugoira-seek-bar"));
this.active = false;
}
set_data_source(data_source)
{
if(data_source == this.data_source)
return;
if(this.data_source != null)
{
this.data_source.remove_update_listener(this.data_source_updated);
this.data_source = null;
}
this.data_source = data_source;
this.ui.data_source = data_source;
if(this.data_source != null)
{
this.data_source.add_update_listener(this.data_source_updated);
this.refresh_ui();
}
}
get _hide_image()
{
return this.container.querySelector(".image-container").hidden;
}
set _hide_image(value)
{
this.container.querySelector(".image-container").hidden = value;
}
// Show an image.
//
// If manga_page isn't null, it's the page to display.
// If manga_page is -1, show the last page.
async show_image(illust_id, manga_page)
{
// If we previously set a pending navigation, this navigation overrides it.
this.cancel_async_navigation();
// Remember that this is the image we want to be displaying.
this.wanted_illust_id = illust_id;
this.wanted_illust_page = manga_page;
// If this image is already loaded, just make sure it's not hidden.
if(illust_id == this.current_illust_id && this.viewer != null && this.wanted_illust_page == this.viewer.page && !this._hide_image)
{
console.log("illust_id", illust_id, "page", this.wanted_illust_page, "already displayed");
return;
}
// If we're not active, stop. We'll show this image if we become loaded later.
if(!this.active)
{
// console.log("not active, set wanted id to", this.wanted_illust_id);
return;
}
// Tell the preloader about the current image.
image_preloader.singleton.set_current_image(illust_id);
var image_container = this.container.querySelector(".image-container");
// If possible, show the quick preview.
this.show_preview(illust_id);
// Load info for this image if needed.
var illust_data = await image_data.singleton().get_image_info(illust_id);
// If this is no longer the image we want to be showing, stop.
if(this.wanted_illust_id != illust_id)
{
console.log("show_image: illust ID changed while async, stopping");
return;
}
// Remove the preview image, if any, since we're starting up the real viewer. Note
// that viewer_illust will create an identical-looking preview once it starts.
this.hide_preview();
// If manga_page is -1, we didn't know the page count when we did the navigation
// and we want the last page. Otherwise, just make sure the page is in range.
if(manga_page == -1)
manga_page = illust_data.pageCount - 1;
else
manga_page = helpers.clamp(manga_page, 0, illust_data.pageCount-1);
console.log("Showing image", illust_id, "page", manga_page);
// If we adjusted the page, update the URL. For single-page posts, there should be
// no page field.
var args = helpers.get_args(document.location);
var wanted_page_arg = illust_data.pageCount > 1? (manga_page + 1).toString():null;
if(args.hash.get("page") != wanted_page_arg)
{
if(wanted_page_arg != null)
args.hash.set("page", wanted_page_arg);
else
args.hash.delete("page");
console.log("Updating URL with page number:", args.hash.toString());
helpers.set_args(args, false /* add_to_history */);
}
// This is the first image we're displaying if we previously had no illust ID, or
// if we were hidden.
var first_image_displayed = this.current_illust_id == -1 || this._hide_image;
// If the illust ID isn't changing, just update the viewed page.
if(illust_id == this.current_illust_id && this.viewer != null)
{
console.log("Image ID not changed, setting page", this.wanted_illust_page);
this._hide_image = false;
this.viewer.page = this.wanted_illust_page;
if(this.manga_thumbnails)
this.manga_thumbnails.current_page_changed(manga_page);
this.refresh_ui();
return;
}
// Speculatively load the next image, which is what we'll show if you press page down, so
// advancing through images is smoother.
//
// We don't do this when showing the first image, since the most common case is simply
// viewing a single image and not navigating to any others, so this avoids making
// speculative loads every time you load a single illustration.
if(!first_image_displayed)
{
// Let image_preloader handle speculative loading. If preload_illust_id is null,
// we're telling it that we don't need to load anything.
var preload_illust_id = this.data_source.id_list.get_neighboring_illust_id(illust_id, this.latest_navigation_direction_down);
image_preloader.singleton.set_speculative_image(preload_illust_id);
}
this.current_illust_id = illust_id;
this.current_illust_data = illust_data;
this.ui.illust_id = illust_id;
this.refresh_ui();
var illust_data = this.current_illust_data;
// If the image has the ドット絵 tag, enable nearest neighbor filtering.
helpers.set_class(document.body, "dot", helpers.tags_contain_dot(illust_data));
// Dismiss any message when changing images.
message_widget.singleton.hide();
// If we're showing something else, remove it.
if(this.viewer != null)
{
this.viewer.shutdown();
this.viewer = null;
}
// The viewer is gone, so we can unhide the image container without flashing the
// previous image.
this._hide_image = false;
// Check if this image is muted.
var muted_tag = muting.singleton.any_tag_muted(illust_data.tags.tags);
var muted_user = muting.singleton.is_muted_user_id(illust_data.userId);
if(muted_tag || muted_user)
{
// Tell the thumbnail view about the image. If the image is muted, disable thumbs.
if(this.manga_thumbnails)
this.manga_thumbnails.set_illust_info(null);
// If the image is muted, load a dummy viewer.
this.viewer = new viewer_muted(image_container, illust_data);
return;
}
var manga_page = this.wanted_illust_page;
if(manga_page == -1)
manga_page = illust_data.pageCount - 1;
// Tell the thumbnail view about the image.
if(this.manga_thumbnails)
{
this.manga_thumbnails.set_illust_info(illust_data);
this.manga_thumbnails.snap_transition();
// Let the manga thumbnail display know about the selected page.
this.manga_thumbnails.current_page_changed(manga_page);
}
// Create the image viewer.
var progress_bar = this.progress_bar.controller();
if(illust_data.illustType == 2)
this.viewer = new viewer_ugoira(image_container, illust_data, this.seek_bar, {
progress_bar: progress_bar,
});
else
{
this.viewer = new viewer_images(image_container, illust_data, {
progress_bar: progress_bar,
manga_page_bar: this.manga_page_bar,
manga_page: manga_page,
});
}
// Refresh the UI now that we have a new viewer.
this.refresh_ui();
}
// If we started navigating to a new image and were delayed to load data (either to load
// the image or to load a new page), cancel it and stay where we are.
cancel_async_navigation()
{
// If we previously set a pending navigation, this navigation overrides it.
if(this.pending_navigation == null)
return;
console.info("Cancelling async navigation");
this.pending_navigation = null;
}
// When loading an image, illust_viewer shows the search thumbnail while loading the main
// image. However, we can only start illust_viewer once we have image info, which causes
// UI delays, even though we often already have enough info to show the preview image
// immediately.
//
// If we have thumbnail data for illust_id and it's a single image (we don't do this for
// manga), create a dummy image viewer to show it until we start the main viewer. The
// image is already cached if we're coming from a search result, so this is often shown
// immediately.
//
// If this shows a preview image, the viewer will be removed.
//
// - this isn't generally needed for manga (if we're coming from the manga viewer then image
// info is already loaded and this is never visible)
// - if we have a way to go directly to the first page of a manga post from search, we could
// do this only if it's the first page (other pages won't match the thumb)
// - if we do that, make sure we don't if the viewer is already pointing at that image
show_preview(illust_id)
{
this.hide_preview();
// See if we already have thumbnail data loaded.
var illust_thumbnail_data = thumbnail_data.singleton().get_one_thumbnail_info(illust_id);
if(illust_thumbnail_data == null)
return;
// We only do this for single images and animations right now.
if(illust_thumbnail_data.pageCount != 1)
return;
// Don't show the preview if this image is muted.
var muted_tag = muting.singleton.any_tag_muted(illust_thumbnail_data.tags);
var muted_user = muting.singleton.is_muted_user_id(illust_thumbnail_data.userId);
if(muted_tag || muted_user)
return;
console.log("Show placeholder for:", illust_thumbnail_data);
this.preview_img = document.createElement("img");
this.preview_img.src = illust_thumbnail_data.url;
this.preview_img.style.pointerEvents = "none";
this.preview_img.classList.add("filtering");
this.preview_img.classList.add("low-res-preview");
var preview_container = this.container.querySelector(".preview-container");
preview_container.appendChild(this.preview_img);
this.preview_on_click_viewer = new on_click_viewer();
this.preview_on_click_viewer.set_new_image(this.preview_img, null, illust_thumbnail_data.width, illust_thumbnail_data.height);
// Don't actually allow zooming the preview, since it'll reset once it's replaced with the real
// viewer. We just create the on_click_viewer to match the zoom with what the real image will
// have.
this.preview_on_click_viewer.disable();
// The preview is taking the place of the viewer until we create it, so remove any existing
// viewer.
if(this.viewer != null)
{
this.viewer.shutdown();
this.viewer = null;
}
}
// Remove our preview image.
hide_preview()
{
if(this.preview_on_click_viewer != null)
{
this.preview_on_click_viewer.disable();
this.preview_on_click_viewer = null;
}
if(this.preview_img != null)
{
this.preview_img.remove();
this.preview_img = null;
}
}
// Stop displaying any image (and cancel any wanted navigation), putting us back
// to where we were before displaying any images.
//
// This will also prevent the next image displayed from triggering speculative
// loading, which we don't want to do when clicking an image in the thumbnail
// view.
stop_displaying_image()
{
if(this.viewer != null)
{
this.viewer.shutdown();
this.viewer = null;
}
if(this.manga_thumbnails)
this.manga_thumbnails.set_illust_info(null);
this.hide_preview();
this.wanted_illust_id = null;
// The manga page to show, or the last page if -1.
this.wanted_illust_page = 0;
this.current_illust_id = -1;
this.refresh_ui();
}
data_source_updated()
{
this.refresh_ui();
}
get active()
{
return this._active;
}
set active(active)
{
if(this._active == active)
return;
this._active = active;
super.active = active;
if(!active)
{
console.log("Hide illust,", this.viewer != null);
this.cancel_async_navigation();
// Remove any image we're displaying, so if we show another image later, we
// won't show the previous image while the new one's data loads.
if(this.viewer != null)
this._hide_image = true;
// Stop showing the user in the context menu.
main_context_menu.get.user_info = null;
return;
}
// If show_image was called while we were inactive, load it now.
if(this.wanted_illust_id != this.current_illust_id || this.wanted_illust_page != this.viewer.page || this._hide_image)
{
// Show the image.
console.log("Showing illust_id", this.wanted_illust_id, "that was set while hidden");
this.show_image(this.wanted_illust_id, this.wanted_illust_page);
}
// If we're becoming active, refresh the UI, since we don't do that while we're inactive.
this.refresh_ui();
}
// Refresh the UI for the current image.
refresh_ui()
{
// Don't refresh if the thumbnail view is active. We're not visible, and we'll just
// step over its page title, etc.
if(!this._active)
return;
// Tell the UI which page is being viewed.
var page = this.viewer != null? this.viewer.page:0;
this.ui.set_displayed_page_info(page);
// Tell the context menu which user is being viewed.
main_context_menu.get.user_info = this.current_illust_data? this.current_illust_data.userInfo:null;
main_context_menu.get.page = page;
// Pull out info about the user and illustration.
var illust_id = this.current_illust_id;
// Update the disable UI button to point at the current image's illustration page.
var disable_button = this.container.querySelector(".disable-ui-button");
disable_button.href = "/artworks/" + illust_id + "#no-ppixiv";
// If we're not showing an image yet, hide the UI and don't try to update it.
helpers.set_class(this.container.querySelector(".ui"), "disabled", illust_id == -1);
helpers.set_title_and_icon(this.current_illust_data);
if(illust_id == -1)
return;
this.ui.refresh();
}
onwheel(e)
{
if(!this._active)
return;
// Don't intercept wheel scrolling over the description box.
if(e.target.closest(".description") != null)
return;
var down = e.deltaY > 0;
this.move(down, e.shiftKey /* skip_manga_pages */);
}
get displayed_illust_id()
{
return this.wanted_illust_id;
}
get displayed_illust_page()
{
return this.wanted_illust_page;
}
handle_onkeydown(e)
{
// Let the viewer handle the input first.
if(this.viewer && this.viewer.onkeydown)
{
this.viewer.onkeydown(e);
if(e.defaultPrevented)
return;
}
this.ui.handle_onkeydown(e);
if(e.defaultPrevented)
return;
if(e.ctrlKey || e.altKey || e.metaKey)
return;
switch(e.keyCode)
{
case 37: // left
case 38: // up
case 33: // pgup
e.preventDefault();
e.stopPropagation();
this.move(false, e.shiftKey /* skip_manga_pages */);
break;
case 39: // right
case 40: // down
case 34: // pgdn
e.preventDefault();
e.stopPropagation();
this.move(true, e.shiftKey /* skip_manga_pages */);
break;
}
}
// Navigate to the next or previous image.
//
// If skip_manga_pages is true, jump past any manga pages in the current illustration. If
// this is true and we're navigating backwards, we'll also jump to the first manga page
// instead of the last.
async move(down, skip_manga_pages)
{
// Remember whether we're navigating forwards or backwards, for preloading.
this.latest_navigation_direction_down = down;
this.cancel_async_navigation();
// See if we should change the manga page.
if(!skip_manga_pages && this.current_illust_data != null && this.current_illust_data.pageCount > 1)
{
var old_page = this.wanted_illust_page;
var new_page = old_page + (down? +1:-1);
new_page = Math.max(0, Math.min(this.current_illust_data.pageCount - 1, new_page));
if(new_page != old_page)
{
main_controller.singleton.show_illust(this.current_illust_id, {
manga_page: new_page,
});
return;
}
}
// If we have a target illust_id, move relative to it. Otherwise, move relative to the
// displayed image. This way, if we navigate repeatedly before a previous navigation
// finishes, we'll keep moving rather than waiting for each navigation to complete.
var navigate_from_illust_id = this.wanted_illust_id;
if(navigate_from_illust_id == null)
navigate_from_illust_id = this.current_illust_id;
// Get the next (or previous) illustration after the current one.
var new_illust_id = this.data_source.id_list.get_neighboring_illust_id(navigate_from_illust_id, down);
if(new_illust_id != null)
{
// Show the new image.
main_controller.singleton.show_illust(new_illust_id, {
manga_page: down || skip_manga_pages? 0:-1,
});
return true;
}
// That page isn't loaded. Try to load it.
var next_page = this.data_source.id_list.get_page_for_neighboring_illust(navigate_from_illust_id, down);
// If we can't find the next page, then the current image isn't actually loaded in
// the current search results. This can happen if the page is reloaded: we'll show
// the previous image, but we won't have the results loaded (and the results may have
// changed). Just jump to the first image in the results so we get back to a place
// we can navigate from.
//
// Note that we use id_list.get_first_id rather than get_current_illust_id, which is
// just the image we're already on.
if(next_page == null)
{
// We should normally know which page the illustration we're currently viewing is on.
console.warn("Don't know the next page for illust", navigate_from_illust_id);
new_illust_id = this.data_source.id_list.get_first_id();
main_controller.singleton.show_illust(new_illust_id);
return true;
}
console.log("Loading the next page of results:", next_page);
// The page shouldn't already be loaded. Double-check to help prevent bugs that might
// spam the server requesting the same page over and over.
if(this.data_source.id_list.is_page_loaded(next_page))
{
console.error("Page", next_page, "is already loaded");
return;
}
// Ask the data source to load it.
var pending_navigation = this.pending_navigation = new Object();
if(!await this.data_source.load_page(next_page))
{
console.log("Reached the end of the list");
return false;
}
// If this.pending_navigation is no longer set to this function, we navigated since
// we requested this load and this navigation is stale, so stop.
if(this.pending_navigation != pending_navigation)
{
console.error("Aborting stale navigation");
return;
}
this.pending_navigation = null;
// If we do have an image displayed, navigate up or down based on our most recent navigation
// direction. This simply retries the navigation now that we have data.
console.log("Retrying navigation after data load");
await this.move(down);
return true;
}
}
// The search UI.
class view_search extends view
{
constructor(container)
{
super(container);
this.thumbs_loaded = this.thumbs_loaded.bind(this);
this.data_source_updated = this.data_source_updated.bind(this);
this.onwheel = this.onwheel.bind(this);
this.onscroll = this.onscroll.bind(this);
// this.onmousemove = this.onmousemove.bind(this);
this.refresh_thumbnail = this.refresh_thumbnail.bind(this);
this.refresh_images = this.refresh_images.bind(this);
this.window_onresize = this.window_onresize.bind(this);
this.update_from_settings = this.update_from_settings.bind(this);
this.thumbnail_onclick = this.thumbnail_onclick.bind(this);
this.active = false;
this.thumbnail_templates = {};
window.addEventListener("thumbnailsLoaded", this.thumbs_loaded);
window.addEventListener("resize", this.window_onresize);
this.container.addEventListener("wheel", this.onwheel, { passive: false });
// this.container.addEventListener("mousemove", this.onmousemove);
this.container.addEventListener("scroll", this.onscroll);
window.addEventListener("resize", this.onscroll);
image_data.singleton().user_modified_callbacks.register(this.refresh_ui.bind(this));
// When a bookmark is modified, refresh the heart icon.
image_data.singleton().illust_modified_callbacks.register(this.refresh_thumbnail);
this.thumbnail_dimensions_style = document.createElement("style");
document.body.appendChild(this.thumbnail_dimensions_style);
// Create the avatar widget shown on the artist data source.
this.avatar_widget = new avatar_widget({
parent: this.container.querySelector(".avatar-container"),
changed_callback: this.data_source_updated,
big: true,
mode: "dropdown",
});
// Create the tag widget used by the search data source.
this.tag_widget = new tag_widget({
parent: this.container.querySelector(".related-tag-list"),
format_link: function(tag)
{
// The recommended tag links are already on the search page, and retain other
// search settings.
var url = new URL(window.location);
url.pathname = "/tags/" + encodeURIComponent(tag);
url.searchParams.delete("p");
return url.toString();
}.bind(this),
});
// Don't scroll thumbnails when scrolling tag dropdowns.
// FIXME: This works on member-tags-box, but not reliably on search-tags-box, even though
// they seem like the same thing.
this.container.querySelector(".member-tags-box .post-tag-list").addEventListener("scroll", function(e) { e.stopPropagation(); }, true);
this.container.querySelector(".search-tags-box .related-tag-list").addEventListener("scroll", function(e) { e.stopPropagation(); }, true);
// Set up hover popups.
dropdown_menu_opener.create_handlers(this.container, [".navigation-menu-box", ".thumbnail-settings-menu-box", ".ages-box", ".popularity-box", ".type-box", ".search-mode-box", ".size-box", ".aspect-ratio-box", ".bookmarks-box", ".time-box", ".member-tags-box", ".search-tags-box"]);
// As an optimization, start loading image info on mousedown. We don't navigate until click,
// but this lets us start loading image info a bit earlier.
this.container.querySelector(".thumbnails").addEventListener("mousedown", (e) => {
if(e.button != 0)
return;
// Don't do this when viewing followed users, since we'll be loading the user rather than the post.
if(this.data_source && this.data_source.search_mode == "users")
return;
var a = e.target.closest("a.thumbnail-link");
if(a == null)
return;
if(a.dataset.illustId != null)
image_data.singleton().get_image_info(a.dataset.illustId);
}, true);
this.container.querySelector(".refresh-search-button").addEventListener("click", this.refresh_search.bind(this));
this.container.querySelector(".whats-new-button").addEventListener("click", this.whats_new.bind(this));
this.container.querySelector(".thumbnails").addEventListener("click", this.thumbnail_onclick);
var settings_menu = this.container.querySelector(".settings-menu-box > .popup-menu-box");
menu_option.add_settings(settings_menu);
settings.register_change_callback("thumbnail-size", () => {
// refresh_images first to update thumbnail_dimensions_style, then call onscroll
// to fill in images.
this.refresh_images();
this.onscroll();
});
settings.register_change_callback("theme", this.update_from_settings);
settings.register_change_callback("disable_thumbnail_zooming", this.update_from_settings);
settings.register_change_callback("disable_thumbnail_panning", this.update_from_settings);
settings.register_change_callback("ui-on-hover", this.update_from_settings);
settings.register_change_callback("no-hide-cursor", this.update_from_settings);
// Create the tag dropdown for the search page input.
new tag_search_box_widget(this.container.querySelector(".tag-search-box"));
// Create the tag dropdown for the search input in the menu dropdown.
new tag_search_box_widget(this.container.querySelector(".navigation-search-box"));
// This IntersectionObserver is used to tell which illustrations are fully visible on screen,
// so we can decide which page to put in the URL for data sources that use supports_start_page.
this.visible_illusts = [];
this.topmost_illust_observer = helpers.intersection_observer((entries) => {
for(let entry of entries)
{
let thumb = entry.target;
if(thumb.dataset.illust_id == null)
continue;
if(entry.isIntersecting)
this.visible_illusts.push(thumb);
else
{
let idx = this.visible_illusts.indexOf(thumb);
if(idx != -1)
this.visible_illusts.splice(idx, 1);
}
}
this.visible_thumbs_changed();
}, {
root: this.container,
// We only want to include fully visible thumbnails. Note that for helpers.intersection_observer
// we need to just use "threshold" and not "thresholds".
threshold: 1,
});
/*
* Add a slight delay before hiding the UI. This allows opening the UI by swiping past the top
* of the window, without it disappearing as soon as the mouse leaves the window. This doesn't
* affect opening the UI.
*
* We're actually handling the manga UI's top-ui-box here too.
*/
for(let box of document.querySelectorAll(".top-ui-box"))
new hover_with_delay(box, 0, 0.25);
this.update_from_settings();
this.refresh_images();
this.load_needed_thumb_data();
this.refresh_whats_new_button();
}
// The thumbnails visible on screen have changed.
visible_thumbs_changed()
{
// visible_illusts isn't in any particular order, but should always be contiguous.
// Start at the first thumb in the list, and walk backwards through thumbs until
// we reach one that isn't in the list. The thumbnail display can get very long,
// but visible_illusts is only the ones on screen.
// Find the earliest thumb in the list.
if(this.visible_illusts.length == 0)
return;
let first_thumb = this.visible_illusts[0];
while(first_thumb != null)
{
let prev_thumb = first_thumb.previousElementSibling;
if(prev_thumb == null)
break;
if(this.visible_illusts.indexOf(prev_thumb) == -1)
break;
first_thumb = prev_thumb;
}
// If the data source supports a start page, update the page number in the URL to reflect
// the first visible thumb.
if(this.data_source == null || !this.data_source.supports_start_page || first_thumb.dataset.page == null)
return;
main_controller.singleton.temporarily_ignore_onpopstate = true;
try {
let args = helpers.get_args(document.location);
this.data_source.set_start_page(args, first_thumb.dataset.page);
helpers.set_args(args, false, "viewing-page");
} finally {
main_controller.singleton.temporarily_ignore_onpopstate = false;
}
}
window_onresize(e)
{
if(!this.active)
return;
this.refresh_images();
}
refresh_search()
{
main_controller.singleton.refresh_current_data_source();
}
// Set or clear the updates class on the "what's new" button.
refresh_whats_new_button()
{
let last_viewed_version = settings.get("whats-new-last-viewed-version", 0);
// This was stored as a string before, since it came from GM_info.script.version. Make
// sure it's an integer.
last_viewed_version = parseInt(last_viewed_version);
let new_updates = last_viewed_version < whats_new.latest_history_revision();
helpers.set_class(this.container.querySelector(".whats-new-button"), "updates", new_updates);
}
whats_new()
{
settings.set("whats-new-last-viewed-version", whats_new.latest_history_revision());
this.refresh_whats_new_button();
new whats_new(document.body.querySelector(".whats-new-box"));
}
/* This scrolls the thumbnail when you hover over it. It's sort of neat, but it's pretty
* choppy, and doesn't transition smoothly when the mouse first hovers over the thumbnail,
* causing it to pop to a new location.
onmousemove(e)
{
var thumb = e.target.closest(".thumbnail-box a");
if(thumb == null)
return;
var bounds = thumb.getBoundingClientRect();
var x = e.clientX - bounds.left;
var y = e.clientY - bounds.top;
x = 100 * x / thumb.offsetWidth;
y = 100 * y / thumb.offsetHeight;
var img = thumb.querySelector("img.thumb");
img.style.objectPosition = x + "% " + y + "%";
}
*/
onwheel(e)
{
// Stop event propagation so we don't change images on any viewer underneath the thumbs.
e.stopPropagation();
};
onscroll(e)
{
this.load_needed_thumb_data();
};
initial_refresh_ui()
{
if(this.data_source != null)
{
var ui_box = this.container.querySelector(".thumbnail-ui-box");
this.data_source.initial_refresh_thumbnail_ui(ui_box, this);
}
}
set_data_source(data_source)
{
if(this.data_source == data_source)
return;
if(this.data_source != null)
{
this.data_source.remove_update_listener(this.data_source_updated);
// Store our scroll position on the data source, so we can restore it if it's
// reactivated. There's only one instance of thumbnail_view, so this is safe.
this.data_source.thumbnail_view_scroll_pos = this.container.scrollTop;
}
// If the search mode is changing (eg. we're going from a list of illustrations to a list
// of users), remove thumbs so we recreate them. Otherwise, refresh_images will reuse them
// and they can be left on the wrong display type.
var old_search_mode = this.data_source? this.data_source.search_mode:"";
var new_search_mode = data_source? data_source.search_mode:"";
if(old_search_mode != new_search_mode)
{
var ul = this.container.querySelector("ul.thumbnails");
while(ul.firstElementChild != null)
{
let node = ul.firstElementChild;
node.remove();
// We should be able to just remove the element and get a callback that it's no longer visible.
// This works in Chrome since IntersectionObserver uses a weak ref, but Firefox is stupid and leaks
// the node.
this.topmost_illust_observer.unobserve(node);
helpers.remove_array_element(this.visible_illusts, node);
}
}
this.data_source = data_source;
if(this.data_source == null)
return;
// If we disabled loading more pages earlier, reenable it.
this.disable_loading_more_pages = false;
// Listen to the data source loading new pages, so we can refresh the list.
this.data_source.add_update_listener(this.data_source_updated);
this.refresh_images();
this.load_needed_thumb_data();
this.initial_refresh_ui();
this.refresh_ui();
};
restore_scroll_position()
{
// If we saved a scroll position when navigating away from a data source earlier,
// restore it now. Only do this once.
if(this.data_source.thumbnail_view_scroll_pos != null)
{
this.container.scrollTop = this.data_source.thumbnail_view_scroll_pos;
delete this.data_source.thumbnail_view_scroll_pos;
}
else
this.scroll_to_top();
}
scroll_to_top()
{
this.container.scrollTop = 0;
}
refresh_ui()
{
if(!this.active)
return;
var element_displaying = this.container.querySelector(".displaying");
element_displaying.hidden = this.data_source.get_displaying_text == null;
if(this.data_source.get_displaying_text != null)
element_displaying.innerText = this.data_source.get_displaying_text();
helpers.set_page_title(this.data_source.page_title || "Loading...");
var ui_box = this.container.querySelector(".thumbnail-ui-box");
this.data_source.refresh_thumbnail_ui(ui_box, this);
this.refresh_ui_for_user_id();
};
// Return the user ID we're viewing, or null if we're not viewing anything specific to a user.
get viewing_user_id()
{
if(this.data_source == null)
return null;
return this.data_source.viewing_user_id;
}
// Call refresh_ui_for_user_info with the user_info for the user we're viewing,
// if the user ID has changed.
async refresh_ui_for_user_id()
{
// If we're viewing ourself (our own bookmarks page), hide the user-related UI.
var initial_user_id = this.viewing_user_id;
var user_id = initial_user_id == window.global_data.user_id? null:initial_user_id;
var user_info = await image_data.singleton().get_user_info_full(user_id);
// Stop if the user ID changed since we started this request, or if we're no longer active.
if(this.viewing_user_id != initial_user_id || !this.active)
return;
helpers.set_icon(null, user_info);
// Set the bookmarks link.
var bookmarks_link = this.container.querySelector(".bookmarks-link");
bookmarks_link.hidden = user_info == null;
if(user_info != null)
{
var bookmarks_url = "/bookmark.php?id=" + user_info.userId + "&rest=show#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)
{
var following_url = "/bookmark.php?id=" + user_info.userId + "&type=user#ppixiv";
following_link.href = following_url;
following_link.dataset.popup = user_info? ("View " + user_info.name + "'s followed users"):"View following";
}
// Set the webpage link.
var webpage_url = user_info && user_info.webpage;
var webpage_link = this.container.querySelector(".webpage-link");
webpage_link.hidden = webpage_url == null;
if(webpage_url != null)
webpage_link.href = webpage_url;
// Set the twitter link.
var twitter_url = user_info && user_info.social && user_info.social.twitter && user_info.social.twitter.url;
var twitter_link = this.container.querySelector(".twitter-icon");
twitter_link.hidden = twitter_url == null;
if(twitter_url != null)
{
twitter_link.href = twitter_url;
var path = new URL(twitter_url).pathname;
var parts = path.split("/");
twitter_link.dataset.popup = parts.length > 1? ("@" + parts[1]):"Twitter";
}
// Set the pawoo link.
var pawoo_url = user_info && user_info.social && user_info.social.pawoo && user_info.social.pawoo.url;
var pawoo_link = this.container.querySelector(".pawoo-icon");
pawoo_link.hidden = pawoo_url == null;
if(pawoo_url != null)
pawoo_link.href = pawoo_url;
// Set the "send a message" link.
var contact_link = this.container.querySelector(".contact-link");
contact_link.hidden = user_info == null;
if(user_info != null)
contact_link.href = "/messages.php?receiver_id=" + user_info.userId;
// Tell the context menu which user is being viewed (if we're viewing a user-specific
// search).
main_context_menu.get.user_info = user_info;
}
set active(active)
{
if(this._active == active)
return;
this._active = active;
super.active = active;
if(active)
{
this.initial_refresh_ui();
this.refresh_ui();
// Refresh the images now, so it's possible to scroll to entries, but wait to start
// loading data to give the caller a chance to call scroll_to_illust_id(), which needs
// to happen after refresh_images but before load_needed_thumb_data. This way, if
// we're showing a page far from the top, we won't load the first page that we're about
// to scroll away from.
this.refresh_images();
setTimeout(function() {
this.load_needed_thumb_data();
}.bind(this), 0);
}
else
{
this.stop_pulsing_thumbnail();
main_context_menu.get.user_info = null;
}
}
get active()
{
return this._active;
}
data_source_updated()
{
this.refresh_images();
this.load_needed_thumb_data();
this.refresh_ui();
}
// Recreate thumbnail images (the actual
elements).
//
// This is done when new pages are loaded, to create the correct number of images.
// We don't need to do this when scrolling around or when new thumbnail data is available.
refresh_images()
{
// Make a list of [illust_id, page] thumbs to add.
var images_to_add = [];
if(this.data_source != null)
{
var id_list = this.data_source.id_list;
var min_page = id_list.get_lowest_loaded_page();
var max_page = id_list.get_highest_loaded_page();
var items_per_page = this.data_source.estimated_items_per_page;
for(var page = min_page; page <= max_page; ++page)
{
var illust_ids = id_list.illust_ids_by_page[page];
if(illust_ids == null)
{
// This page isn't loaded. Fill the gap with items_per_page blank entries.
for(var idx = 0; idx < items_per_page; ++idx)
images_to_add.push([null, page]);
continue;
}
// Create an image for each ID.
for(var illust_id of illust_ids)
images_to_add.push({id: illust_id, page: page});
}
// If this data source supports a start page and we started after page 1, add the "load more"
// button at the beginning.
//
// The page number for this button is the same as the thumbs that follow it, not the
// page it'll load if clicked, so scrolling to it doesn't make us think we're scrolled
// to that page.
if(this.data_source.initial_page > 1)
images_to_add.splice(0, 0, { id: "special:previous-page", page: this.data_source.initial_page });
}
// Add thumbs.
//
// Most of the time we're just adding thumbs to the list. Avoid removing or recreating
// thumbs that aren't actually changing, which reduces flicker.
//
// Do this by looking for a range of thumbnails that matches a range in images_to_add.
// If we're going to display [0,1,2,3,4,5,6,7,8,9], and the current thumbs are [4,5,6],
// then 4,5,6 matches and can be reused. We'll add [0,1,2,3] to the beginning and [7,8,9]
// to the end.
//
// Most of the time we're just appending. The main time that we add to the beginning is
// the "load previous results" button.
var ul = this.container.querySelector("ul.thumbnails");
var next_node = ul.firstElementChild;
// Make a dictionary of all illust IDs and pages, so we can look them up quickly.
let images_to_add_index = {};
for(let i = 0; i < images_to_add.length; ++i)
{
let entry = images_to_add[i];
let illust_id = entry.id;
let page = entry.page;
let index = illust_id + "/" + page;
images_to_add_index[index] = i;
}
let get_node_idx = function(node)
{
if(node == null)
return null;
let illust_id = node.dataset.illust_id;
let page = node.dataset.page;
let index = illust_id + "/" + page;
return images_to_add_index[index];
}
// Find the first match (4 in the above example).
let first_matching_node = next_node;
while(first_matching_node && get_node_idx(first_matching_node) == null)
first_matching_node = first_matching_node.nextElementSibling;
// If we have a first_matching_node, walk forward to find the last matching node (6 in
// the above example).
let last_matching_node = first_matching_node;
if(last_matching_node != null)
{
// Make sure the range is contiguous. first_matching_node and all nodes through last_matching_node
// should match a range exactly. If there are any missing entries, stop.
let next_expected_idx = get_node_idx(last_matching_node) + 1;
while(last_matching_node && get_node_idx(last_matching_node.nextElementSibling) == next_expected_idx)
{
last_matching_node = last_matching_node.nextElementSibling;
next_expected_idx++;
}
}
// If we have a matching range, save the scroll position relative to it, so if we add
// new elements at the top, we stay scrolled where we are. Otherwise, just restore the
// current scroll position.
let save_scroll = new SaveScrollPosition(this.container);
if(first_matching_node)
save_scroll.save_relative_to(first_matching_node);
// If we have a range, delete all items outside of it. Otherwise, just delete everything.
while(first_matching_node && first_matching_node.previousElementSibling)
first_matching_node.previousElementSibling.remove();
while(last_matching_node && last_matching_node.nextElementSibling)
last_matching_node.nextElementSibling.remove();
if(!first_matching_node && !last_matching_node)
helpers.remove_elements(ul);
// If we have a matching range, add any new elements before it.
if(first_matching_node)
{
let first_idx = get_node_idx(first_matching_node);
for(let idx = first_idx - 1; idx >= 0; --idx)
{
let entry = images_to_add[idx];
var illust_id = entry.id;
var page = entry.page;
var node = this.create_thumb(illust_id, page);
first_matching_node.insertAdjacentElement("beforebegin", node);
first_matching_node = node;
}
}
// Add any new elements after the range. If we don't have a range, just add everything.
let last_idx = -1;
if(last_matching_node)
last_idx = get_node_idx(last_matching_node);
for(let idx = last_idx + 1; idx < images_to_add.length; ++idx)
{
let entry = images_to_add[idx];
var illust_id = entry.id;
var page = entry.page;
var node = this.create_thumb(illust_id, page);
ul.appendChild(node);
}
if(this.container.offsetWidth == 0)
return;
let thumbnail_size = settings.get("thumbnail-size", 4);
thumbnail_size = thumbnail_size_slider_widget.thumbnail_size_for_value(thumbnail_size);
this.thumbnail_dimensions_style.textContent = helpers.make_thumbnail_sizing_style(ul, ".view-search-container", {
wide: true,
size: thumbnail_size,
max_columns: 5,
// Set a minimum padding to make sure there's room for the popup text to fit between images.
min_padding: 15,
});
// Restore the value of scrollTop from before we updated. For some reason, Firefox
// modifies scrollTop after we add a bunch of items, which causes us to scroll to
// the wrong position, even though scrollRestoration is disabled.
save_scroll.restore();
}
// Start loading data pages that we need to display visible thumbs, and start
// loading thumbnail data for nearby thumbs.
async load_needed_thumb_data()
{
// elements is a list of elements that are onscreen (or close to being onscreen).
// We want thumbnails loaded for these, even if we need to load more thumbnail data.
//
// nearby_elements is a list of elements that are a bit further out. If we load
// thumbnail data for elements, we'll load these instead. That way, if we scroll
// up a bit and two more thumbs become visible, we'll load a bigger chunk.
// That way, we make fewer batch requests instead of requesting two or three
// thumbs at a time.
// Make a list of pages that we need loaded, and illustrations that we want to have
// set.
var wanted_illust_ids = [];
var need_thumbnail_data = false;
var elements = this.get_visible_thumbnails(false);
for(var element of elements)
{
if(element.dataset.illust_id != null)
{
// If this is an illustration, add it to wanted_illust_ids so we load its thumbnail
// info. Don't do this if it's a user.
if(helpers.id_type(element.dataset.illust_id) == "illust")
wanted_illust_ids.push(element.dataset.illust_id);
}
}
// We load pages when the last thumbs on the previous page are loaded, but the first
// time through there's no previous page to reach the end of. Always make sure the
// first page is loaded (usually page 1).
let load_page = null;
let first_page = this.data_source? this.data_source.initial_page:1;
if(this.data_source && !this.data_source.is_page_loaded_or_loading(first_page))
load_page = first_page;
// If the last thumb in the list is being loaded, we need the next page to continue.
// Note that since get_visible_thumbnails returns thumbs before they actually scroll
// into view, this will happen before the last thumb is actually visible to the user.
var ul = this.container.querySelector("ul.thumbnails");
if(load_page == null && elements.length > 0 && elements[elements.length-1] == ul.lastElementChild)
{
let last_element = elements[elements.length-1];
load_page = parseInt(last_element.dataset.page)+1;
}
if(load_page != null)
{
console.log("Load page", load_page, "for thumbnails");
var result = await this.data_source.load_page(load_page);
// If this page didn't load, it probably means we've reached the end, so stop trying
// to load more pages.
if(!result)
{
this.disable_loading_more_pages = true;
// If this is the first page and there are no results, then there are no images
// for this search.
if(load_page == 1)
{
console.log("No results on page 1. Showing no results");
message_widget.singleton.show("No results");
message_widget.singleton.center();
message_widget.singleton.clear_timer();
}
}
}
if(!thumbnail_data.singleton().are_all_ids_loaded_or_loading(wanted_illust_ids))
{
// At least one visible thumbnail needs to be loaded, so load more data at the same
// time.
var nearby_elements = this.get_visible_thumbnails(true);
var nearby_illust_ids = [];
for(var element of nearby_elements)
{
if(element.dataset.illust_id == null)
continue;
nearby_illust_ids.push(element.dataset.illust_id);
}
// console.log("Wanted:", wanted_illust_ids.join(", "));
// console.log("Nearby:", nearby_illust_ids.join(", "));
// Load the thumbnail data if needed.
thumbnail_data.singleton().get_thumbnail_info(nearby_illust_ids);
}
this.set_visible_thumbs();
}
// Handle clicks on the "load previous results" button.
async thumbnail_onclick(e)
{
let thumb = e.target.closest(".thumbnail-load-previous");
if(thumb == null)
return;
e.preventDefault();
e.stopImmediatePropagation();
let min_page = this.data_source.id_list.get_lowest_loaded_page();
if(min_page == 1)
{
console.log("Already at page 1, load previous button shouldn't have been visible");
return;
}
let load_page = min_page - 1;
console.log("Loading previous page:", load_page);
await this.data_source.load_page(load_page);
}
update_from_settings()
{
var thumbnail_mode = helpers.get_value("thumbnail-size");
this.set_visible_thumbs();
this.refresh_images();
helpers.set_class(document.body, "light", settings.get("theme") == "light");
helpers.set_class(document.body, "disable-thumbnail-panning", settings.get("disable_thumbnail_panning"));
helpers.set_class(document.body, "disable-thumbnail-zooming", settings.get("disable_thumbnail_zooming"));
helpers.set_class(document.body, "ui-on-hover", settings.get("ui-on-hover"));
// Flush the top UI transition, so it doesn't animate weirdly when toggling ui-on-hover.
for(let box of document.querySelectorAll(".top-ui-box"))
{
box.classList.add("disable-transition");
box.offsetHeight;
box.classList.remove("disable-transition");
}
}
// Set the URL for all loaded thumbnails that are onscreen.
//
// This won't trigger loading any data (other than the thumbnails themselves).
set_visible_thumbs()
{
// Make a list of IDs that we're assigning.
var elements = this.get_visible_thumbnails();
var illust_ids = [];
for(var element of elements)
{
if(element.dataset.illust_id == null)
continue;
illust_ids.push(element.dataset.illust_id);
}
for(var element of elements)
{
var illust_id = element.dataset.illust_id;
if(illust_id == null)
continue;
var search_mode = this.data_source.search_mode;
let thumb_type = helpers.id_type(illust_id);
let thumb_id = helpers.actual_id(illust_id);
let thumb_data = {};
// For illustrations, get thumbnail info. If we don't have it yet, skip the image (leave it pending)
// and we'll come back once we have it.
if(thumb_type == "illust")
{
// Get thumbnail info.
var info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id);
if(info == null)
continue;
}
// Leave it alone if it's already been loaded.
if(!("pending" in element.dataset))
continue;
// Why is this not working in FF? It works in the console, but not here. Sandboxing
// issue?
// delete element.dataset.pending;
element.removeAttribute("data-pending");
if(thumb_type == "user")
{
// This is a user thumbnail rather than an illustration thumbnail. It just shows a small subset
// of info.
let user_id = helpers.actual_id(illust_id);
var link = element.querySelector("a.thumbnail-link");
link.href = "/users/" + user_id + "#ppixiv";
link.dataset.userId = user_id;
let quick_user_data = thumbnail_data.singleton().get_quick_user_data(user_id);
if(quick_user_data == null)
{
// We should always have this data for users if the data source asked us to display this user.
throw "Missing quick user data for user ID " + user_id;
}
var thumb = element.querySelector(".thumb");
thumb.src = quick_user_data.profileImageUrl;
var label = element.querySelector(".thumbnail-label");
label.hidden = false;
label.querySelector(".label").innerText = quick_user_data.userName;
// Point the "similar illustrations" thumbnail button to similar users for this result, so you can
// chain from one set of suggested users to another.
element.querySelector("A.similar-illusts-button").href = "/discovery/users#ppixiv?user_id=" + user_id;
continue;
}
if(illust_id == "special:previous-page")
{
// Set the link for previous-page. Most of the time this is handled by our in-page click handler.
let args = helpers.get_args(document.location);
let page = this.data_source.get_start_page(args);
this.data_source.set_start_page(args, page-1);
element.querySelector("a.load-previous-page-link").href = helpers.get_url_from_args(args);
continue;
}
if(thumb_type != "illust")
throw "Unexpected thumb type: " + thumb_type;
// Set this thumb.
var url = info.url;
var thumb = element.querySelector(".thumb");
// Check if this illustration is muted (blocked).
var muted_tag = muting.singleton.any_tag_muted(info.tags);
var muted_user = muting.singleton.is_muted_user_id(info.userId);
if(muted_tag || muted_user)
{
element.classList.add("muted");
// The image will be obscured, but we still shouldn't load the image the user blocked (which
// is something Pixiv does wrong). Load the user profile image instead.
thumb.src = info.profileImageUrl;
element.querySelector(".muted-label").textContent = muted_tag? muted_tag:info.userName;
// We can use this if we want a "show anyway' UI.
thumb.dataset.mutedUrl = url;
}
else
{
thumb.src = url;
// The search page thumbs are always square (aspect ratio 1).
helpers.set_thumbnail_panning_direction(element, info.width, info.height, 1);
}
// Set the link. Setting dataset.illustId will allow this to be handled with in-page
// navigation, and the href will allow middle click, etc. to work normally.
//
// If we're on the followed users page, set these to the artist page instead.
var link = element.querySelector("a.thumbnail-link");
if(search_mode == "users") {
link.href = "/users/" + info.userId + "#ppixiv";
}
else
{
link.href = "/artworks/" + illust_id + "#ppixiv";
}
link.dataset.illustId = illust_id;
link.dataset.userId = info.userId;
// Don't show this UI when we're in the followed users view.
if(search_mode == "illusts")
{
if(info.illustType == 2)
element.querySelector(".ugoira-icon").hidden = false;
if(info.pageCount > 1)
{
var pageCountBox = element.querySelector(".page-count-box");
pageCountBox.hidden = false;
pageCountBox.href = link.href + "?view=manga";
element.querySelector(".page-count-box .page-count").textContent = info.pageCount;
}
}
helpers.set_class(element, "dot", helpers.tags_contain_dot(info));
// On most pages, the suggestions button in thumbnails shows similar illustrations. On following,
// show similar artists instead.
if(search_mode == "users")
element.querySelector("A.similar-illusts-button").href = "/discovery/users#ppixiv?user_id=" + info.userId;
else
element.querySelector("A.similar-illusts-button").href = "/bookmark_detail.php?illust_id=" + illust_id + "#ppixiv";
this.refresh_bookmark_icon(element);
// Set the label. This is only actually shown in following views.
var label = element.querySelector(".thumbnail-label");
if(search_mode == "users") {
label.hidden = false;
label.querySelector(".label").innerText = info.userName;
} else {
label.hidden = true;
}
}
}
// Refresh the thumbnail for illust_id.
//
// This is used to refresh the bookmark icon when changing a bookmark.
refresh_thumbnail(illust_id)
{
var ul = this.container.querySelector("ul.thumbnails");
var thumbnail_element = ul.querySelector("[data-illust_id=\"" + illust_id + "\"]");
if(thumbnail_element == null)
return;
this.refresh_bookmark_icon(thumbnail_element);
}
// Set the bookmarked heart for thumbnail_element. This can change if the user bookmarks
// or un-bookmarks an image.
refresh_bookmark_icon(thumbnail_element)
{
if(this.data_source && this.data_source.search_mode == "users")
return;
var illust_id = thumbnail_element.dataset.illust_id;
if(illust_id == null)
return;
// Get thumbnail info.
var thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id);
if(thumbnail_info == null)
return;
var show_bookmark_heart = thumbnail_info.bookmarkData != null;
if(this.data_source != null && !this.data_source.show_bookmark_icons)
show_bookmark_heart = false;
thumbnail_element.querySelector(".heart.public").hidden = !show_bookmark_heart || thumbnail_info.bookmarkData.private;
thumbnail_element.querySelector(".heart.private").hidden = !show_bookmark_heart || !thumbnail_info.bookmarkData.private;
}
// Return a list of thumbnails that are either visible, or close to being visible
// (so we load thumbs before they actually come on screen).
//
// If extra is true, return more offscreen thumbnails.
get_visible_thumbnails(extra)
{
// If the container has a zero height, that means we're hidden and we don't want to load
// thumbnail data at all.
if(this.container.offsetHeight == 0)
return [];
// We'll load thumbnails when they're within this number of pixels from being onscreen.
var threshold = 450;
var ul = this.container.querySelector("ul.thumbnails");
var elements = [];
var bounds_top = this.container.scrollTop - threshold;
var bounds_bottom = this.container.scrollTop + this.container.offsetHeight + threshold;
for(var element = ul.firstElementChild; element != null; element = element.nextElementSibling)
{
if(element.offsetTop + element.offsetHeight < bounds_top)
continue;
if(element.offsetTop > bounds_bottom)
continue;
elements.push(element);
}
if(extra)
{
// Expand the list outwards to include more thumbs.
var expand_by = 20;
var expand_upwards = true;
while(expand_by > 0)
{
if(!elements[0].previousElementSibling && !elements[elements.length-1].nextElementSibling)
{
// Stop if there's nothing above or below our results to add.
break;
}
if(!expand_upwards && elements[0].previousElementSibling)
{
elements.unshift(elements[0].previousElementSibling);
expand_by--;
}
else if(expand_upwards && elements[elements.length-1].nextElementSibling)
{
elements.push(elements[elements.length-1].nextElementSibling);
expand_by--;
}
expand_upwards = !expand_upwards;
}
}
return elements;
}
// Create a thumb placeholder. This doesn't load the image yet.
//
// illust_id is the illustration this will be if it's displayed, or null if this
// is a placeholder for pages we haven't loaded. page is the page this illustration
// is on (whether it's a placeholder or not).
create_thumb(illust_id, page)
{
let template_type = ".template-thumbnail";
if(illust_id == "special:previous-page")
template_type = ".template-load-previous-results";
// Cache a reference to the thumbnail template. We can do this a lot, and this
// query takes a lot of page setup time if we run it for each thumb.
if(this.thumbnail_templates[template_type] == null)
this.thumbnail_templates[template_type] = document.body.querySelector(template_type);
let entry = helpers.create_from_template(this.thumbnail_templates[template_type]);
// Mark that this thumb hasn't been filled in yet.
entry.dataset.pending = true;
if(illust_id != null)
entry.dataset.illust_id = illust_id;
entry.dataset.page = page;
this.topmost_illust_observer.observe(entry);
return entry;
}
// This is called when thumbnail_data has loaded more thumbnail info.
thumbs_loaded(e)
{
this.set_visible_thumbs();
}
// Scroll to illust_id if it's available. This is called when we display the thumbnail view
// after coming from an illustration.
scroll_to_illust_id(illust_id)
{
var thumb = this.container.querySelector("li[data-illust_id='" + illust_id + "']");
if(thumb == null)
return;
// If the item isn't visible, center it.
var scroll_pos = this.container.scrollTop;
if(thumb.offsetTop < scroll_pos || thumb.offsetTop + thumb.offsetHeight > scroll_pos + this.container.offsetHeight)
this.container.scrollTop = thumb.offsetTop + thumb.offsetHeight/2 - this.container.offsetHeight/2;
};
pulse_thumbnail(illust_id)
{
var thumb = this.container.querySelector("li[data-illust_id='" + illust_id + "']");
if(thumb == null)
return;
this.stop_pulsing_thumbnail();
this.flashing_image = thumb;
thumb.classList.add("flash");
};
// Work around a bug in CSS animations: even if animation-iteration-count is 1,
// the animation will play again if the element is hidden and displayed again, which
// causes previously-flashed thumbnails to flash every time we exit and reenter
// thumbnails.
stop_pulsing_thumbnail()
{
if(this.flashing_image == null)
return;
this.flashing_image.classList.remove("flash");
this.flashing_image = null;
};
};
// A full page viewer for manga thumbnails.
//
// This is similar to the main search view. It doesn't share code, since it
// works differently enough that it would complicate things too much.
class view_manga extends view
{
constructor(container)
{
super(container);
this.refresh_ui = this.refresh_ui.bind(this);
this.window_onresize = this.window_onresize.bind(this);
this.refresh_images = this.refresh_images.bind(this);
window.addEventListener("resize", this.window_onresize);
this.progress_bar = main_controller.singleton.progress_bar;
this.ui = new image_ui(this.container.querySelector(".ui-container"), this.progress_bar);
this.scroll_positions_by_illust_id = {};
image_data.singleton().user_modified_callbacks.register(this.refresh_ui);
image_data.singleton().illust_modified_callbacks.register(this.refresh_ui);
settings.register_change_callback("manga-thumbnail-size", this.refresh_images);
// Create a style for our thumbnail style.
this.thumbnail_dimensions_style = document.createElement("style");
document.body.appendChild(this.thumbnail_dimensions_style);
this.active = false;
}
window_onresize(e)
{
if(!this.active)
return;
console.log("resize");
this.refresh_images();
}
set active(active)
{
if(this.active == active)
return;
this._active = active;
if(!active)
{
// Save the old scroll position.
if(this.illust_id != null)
{
console.log("save scroll position for", this.illust_id, this.container.scrollTop);
this.scroll_positions_by_illust_id[this.illust_id] = this.container.scrollTop;
}
// Hide the dropdown tag widget.
this.ui.bookmark_tag_widget.visible = false;
// Stop showing the user in the context menu.
main_context_menu.get.user_info = null;
}
super.active = active;
if(active)
this.load_illust_id();
}
get active()
{
return this._active;
}
get shown_illust_id()
{
return this.illust_id;
}
set shown_illust_id(illust_id)
{
if(this.illust_id == illust_id)
return;
// The load itself is async and might not happen immediately if we don't have page info yet.
// Clear any previous image list so it doesn't flash on screen while we load the new info.
let ul = this.container.querySelector("ul.thumbnails");
helpers.remove_elements(ul);
this.illust_id = illust_id;
this.illust_info = null;
this.ui.illust_id = illust_id;
// Refresh even if illust_id is null, so we quickly clear the view.
this.refresh_ui();
if(this.illust_id == null)
return;
if(!this.active)
return;
this.load_illust_id();
}
async load_illust_id()
{
if(this.illust_id == null)
return;
console.log("Loading manga view for:", this.illust_id);
// Load image info.
var illust_info = await image_data.singleton().get_image_info(this.illust_id);
if(illust_info.id != this.illust_id)
return;
this.illust_info = illust_info;
this.refresh_ui();
}
get displayed_illust_id()
{
return this.illust_id;
}
refresh_ui()
{
if(!this._active)
return;
helpers.set_title_and_icon(this.illust_info);
// Tell the context menu which user is being viewed.
main_context_menu.get.user_info = this.illust_info.userInfo;
this.refresh_images();
}
refresh_images()
{
var original_scroll_top = this.container.scrollTop;
// Remove all existing entries and collect them.
var ul = this.container.querySelector("ul.thumbnails");
helpers.remove_elements(ul);
if(this.illust_info == null)
return;
// Get the aspect ratio to crop images to.
var ratio = this.get_display_aspect_ratio(this.illust_info.mangaPages);
//
// console.log("size", size);
let thumbnail_size = settings.get("manga-thumbnail-size", 4);
thumbnail_size = thumbnail_size_slider_widget.thumbnail_size_for_value(thumbnail_size);
this.thumbnail_dimensions_style.textContent = helpers.make_thumbnail_sizing_style(ul, ".view-manga-container", {
wide: true,
size: thumbnail_size,
ratio: ratio,
// We preload this page anyway since it doesn't cause a lot of API calls, so we
// can allow a high column count and just let the size take over.
max_columns: 15,
});
for(var page = 0; page < this.illust_info.mangaPages.length; ++page)
{
var manga_page = this.illust_info.mangaPages[page];
var entry = this.create_thumb(page, manga_page);
var link = entry.querySelector(".thumbnail-link");
helpers.set_thumbnail_panning_direction(entry, manga_page.width, manga_page.height, ratio);
ul.appendChild(entry);
}
// Restore the value of scrollTop from before we updated. For some reason, Firefox
// modifies scrollTop after we add a bunch of items, which causes us to scroll to
// the wrong position, even though scrollRestoration is disabled.
this.container.scrollTop = original_scroll_top;
}
// Given a list of manga infos, return the aspect ratio we'll crop them to.
get_display_aspect_ratio(manga_info)
{
// A lot of manga posts use the same resolution for all images, or just have
// one or two exceptions for things like title pages. If most images have
// about the same aspect ratio, use it.
var total = 0;
for(var manga_page of manga_info)
total += manga_page.width / manga_page.height;
var average_aspect_ratio = total / manga_info.length;
var illusts_far_from_average = 0;
for(var manga_page of manga_info)
{
var ratio = manga_page.width / manga_page.height;
if(Math.abs(average_aspect_ratio - ratio) > 0.1)
illusts_far_from_average++;
}
// If we didn't find a common aspect ratio, just use square thumbs.
if(illusts_far_from_average > 3)
return 1;
else
return average_aspect_ratio;
}
get_display_resolution(width, height)
{
var fit_width = 300;
var fit_height = 300;
var ratio = width / fit_width;
if(ratio > 1)
{
height /= ratio;
width /= ratio;
}
var ratio = height / fit_height;
if(ratio > 1)
{
height /= ratio;
width /= ratio;
}
return [width, height];
}
create_thumb(page_idx, manga_page)
{
if(this.thumbnail_template == null)
this.thumbnail_template = document.body.querySelector(".template-manga-view-thumbnail");
var element = helpers.create_from_template(this.thumbnail_template);
// These URLs should be the 540x540_70 master version, which is a non-squared high-res
// thumbnail. These tend to be around 30-40k, so loading a full manga set of them is
// quick.
//
// XXX: switch this to 540x540_10_webp in Chrome, around 5k?
var thumb = element.querySelector(".thumb");
var url = manga_page.urls.small;
// url = url.replace("/540x540_70/", "/540x540_10_webp/");
thumb.src = url;
var size = this.get_display_resolution(manga_page.width, manga_page.height);
thumb.width = size[0];
thumb.height = size[1];
var link = element.querySelector("a.thumbnail-link");
link.href = "/artworks/" + this.illust_id + "#ppixiv?page=" + (page_idx+1);
link.dataset.illustId = this.illust_id;
link.dataset.pageIdx = page_idx;
element.dataset.pageIdx = page_idx;
return element;
}
scroll_to_top()
{
// Read offsetHeight to force layout to happen. If we don't do this, setting scrollTop
// sometimes has no effect in Firefox.
this.container.offsetHeight;
this.container.scrollTop = 0;
console.log("scroll to top", this.container.scrollTop, this.container.hidden, this.container.offsetHeight);
}
restore_scroll_position()
{
// If we saved a scroll position when navigating away from a data source earlier,
// restore it now. Only do this once.
var scroll_pos = this.scroll_positions_by_illust_id[this.illust_id];
if(scroll_pos != null)
{
console.log("scroll pos:", scroll_pos);
this.container.scrollTop = scroll_pos;
delete this.scroll_positions_by_illust_id[this.illust_id];
}
else
this.scroll_to_top();
}
scroll_to_illust_id(illust_id, manga_page)
{
if(manga_page == null)
return;
var thumb = this.container.querySelector('[data-page-idx="' + manga_page + '"]');
if(thumb == null)
return;
console.log("Scrolling to", thumb);
// If the item isn't visible, center it.
var scroll_pos = this.container.scrollTop;
if(thumb.offsetTop < scroll_pos || thumb.offsetTop + thumb.offsetHeight > scroll_pos + this.container.offsetHeight)
this.container.scrollTop = thumb.offsetTop + thumb.offsetHeight/2 - this.container.offsetHeight/2;
}
handle_onkeydown(e)
{
this.ui.handle_onkeydown(e);
}
}
// This handles the overlay UI on the illustration page.
class image_ui
{
constructor(container, progress_bar)
{
this.image_data_loaded = this.image_data_loaded.bind(this);
this.clicked_download = this.clicked_download.bind(this);
this.refresh = this.refresh.bind(this);
this.container = container;
this.progress_bar = progress_bar;
this.ui = helpers.create_from_template(".template-image-ui");
this.container.appendChild(this.ui);
this.avatar_widget = new avatar_widget({
parent: this.container.querySelector(".avatar-popup"),
mode: "dropdown",
});
this.tag_widget = new tag_widget({
parent: this.container.querySelector(".tag-list"),
});
// Set up hover popups.
dropdown_menu_opener.create_handlers(this.container, [".image-settings-menu-box"]);
image_data.singleton().illust_modified_callbacks.register(this.refresh);
this.bookmark_tag_widget = new bookmark_tag_list_widget(this.container.querySelector(".popup-bookmark-tag-dropdown-container"));
this.toggle_tag_widget = new toggle_bookmark_tag_list_widget(this.container.querySelector(".button-bookmark-tags"), this.bookmark_tag_widget);
this.like_button = new like_button_widget(this.container.querySelector(".button-like"));
// The bookmark buttons, and clicks in the tag dropdown:
this.bookmark_buttons = [];
for(var a of this.container.querySelectorAll(".button-bookmark"))
this.bookmark_buttons.push(new bookmark_button_widget(a, a.classList.contains("private"), this.bookmark_tag_widget));
this.container.querySelector(".download-button").addEventListener("click", this.clicked_download);
this.container.querySelector(".navigate-out-button").addEventListener("click", function(e) {
main_controller.singleton.navigate_out();
}.bind(this));
var settings_menu = this.container.querySelector(".settings-menu-box > .popup-menu-box");
menu_option.add_settings(settings_menu);
}
set data_source(data_source)
{
if(this._data_source == data_source)
return;
this._data_source = data_source;
this.refresh();
}
shutdown()
{
image_data.singleton().illust_modified_callbacks.unregister(this.refresh);
this.avatar_widget.shutdown();
}
get illust_id()
{
return this._illust_id;
}
set illust_id(illust_id)
{
if(this._illust_id == illust_id)
return;
this._illust_id = illust_id;
this.illust_data = null;
image_data.singleton().get_image_info(illust_id).then((illust_info) => {
this.image_data_loaded(illust_info);
}).catch((e) => {
console.error(e);
});
this.like_button.illust_id = illust_id;
this.bookmark_tag_widget.illust_id = illust_id;
this.toggle_tag_widget.illust_id = illust_id;
for(var button of this.bookmark_buttons)
button.illust_id = illust_id;
}
handle_onkeydown(e)
{
this.avatar_widget.handle_onkeydown(e);
if(e.defaultPrevented)
return;
if(e.keyCode == 66) // b
{
// b to bookmark publically, B to bookmark privately, ^B to remove a bookmark.
//
// Use a separate hotkey to remove bookmarks, rather than toggling like the bookmark
// button does, so you don't have to check whether an image is bookmarked. You can
// just press B to bookmark without worrying about accidentally removing a bookmark
// instead.
e.stopPropagation();
e.preventDefault();
var illust_data = this.illust_data;
if(illust_data == null)
return;
if(e.ctrlKey)
{
// Remove the bookmark.
if(illust_data.bookmarkData == null)
{
message_widget.singleton.show("Image isn't bookmarked");
return;
}
actions.bookmark_remove(illust_data);
return;
}
if(illust_data.bookmarkData)
{
message_widget.singleton.show("Already bookmarked (^B to remove bookmark)");
return;
}
actions.bookmark_add(illust_data, {
private: e.shiftKey
});
return;
}
if(e.ctrlKey || e.altKey || e.metaKey)
return;
switch(e.keyCode)
{
case 86: // v
e.stopPropagation();
e.preventDefault();
actions.like_image(this.illust_data);
return;
}
}
image_data_loaded(illust_data)
{
if(illust_data.illustId != this._illust_id)
return;
this.illust_data = illust_data;
this.refresh();
}
refresh()
{
if(this.illust_data == null)
return;
var illust_data = this.illust_data;
var illust_id = illust_data.illustId;
var user_data = illust_data.userInfo;
// Show the author if it's someone else's post, or the edit link if it's ours.
var our_post = global_data.user_id == user_data.userId;
this.container.querySelector(".author-block").hidden = our_post;
this.container.querySelector(".edit-post").hidden = !our_post;
this.container.querySelector(".edit-post").href = "/member_illust_mod.php?mode=mod&illust_id=" + illust_id;
this.avatar_widget.set_from_user_data(user_data);
this.tag_widget.set(illust_data.tags);
var element_title = this.container.querySelector(".title");
element_title.textContent = illust_data.illustTitle;
element_title.href = "/artworks/" + illust_id + "#ppixiv";
var element_author = this.container.querySelector(".author");
element_author.textContent = user_data.name;
element_author.href = "/users/" + user_data.userId + "#ppixiv";
this.container.querySelector(".similar-illusts-button").href = "/bookmark_detail.php?illust_id=" + illust_id + "#ppixiv";
this.container.querySelector(".similar-artists-button").href = "/discovery/users#ppixiv?user_id=" + user_data.userId;
// Fill in the post info text.
this.set_post_info(this.container.querySelector(".post-info"));
// The comment (description) can contain HTML.
var element_comment = this.container.querySelector(".description");
element_comment.hidden = illust_data.illustComment == "";
element_comment.innerHTML = illust_data.illustComment;
helpers.fix_pixiv_links(element_comment);
helpers.make_pixiv_links_internal(element_comment);
// Set the download button popup text.
if(this.illust_data != null)
{
var download_type = actions.get_download_type_for_image(this.illust_data);
var download_button = this.container.querySelector(".download-button");
download_button.hidden = download_type == null;
if(download_type != null)
download_button.dataset.popup = "Download " + download_type;
}
// Set the popup for the thumbnails button.
var navigate_out_label = main_controller.singleton.navigate_out_label;
var title = navigate_out_label != null? ("Return to " + navigate_out_label):"";
this.container.querySelector(".navigate-out-button").dataset.popup = title;
}
set_post_info(post_info_container)
{
var illust_data = this.illust_data;
var set_info = (query, text) =>
{
var node = post_info_container.querySelector(query);
node.innerText = text;
node.hidden = text == "";
};
var seconds_old = (new Date() - new Date(illust_data.createDate)) / 1000;
set_info(".post-age", helpers.age_to_string(seconds_old) + " ago");
post_info_container.querySelector(".post-age").dataset.popup = helpers.date_to_string(illust_data.createDate);
var info = "";
// Add the resolution and file type if available.
if(this.displayed_page != null && this.illust_data != null)
{
var page_info = this.illust_data.mangaPages[this.displayed_page];
page_info.width;
info += page_info.width + "x" + page_info.height;
}
var ext = this.viewer? this.viewer.current_image_type:null;
if(ext != null)
info += " " + ext;
set_info(".image-info", info);
var duration = "";
if(illust_data.illustType == 2)
{
var seconds = 0;
for(var frame of illust_data.ugoiraMetadata.frames)
seconds += frame.delay / 1000;
var duration = seconds.toFixed(duration >= 10? 0:1);
duration += seconds == 1? " second":" seconds";
}
set_info(".ugoira-duration", duration);
set_info(".ugoira-frames", illust_data.illustType == 2? (illust_data.ugoiraMetadata.frames.length + " frames"):"");
// Add the page count for manga.
var page_text = "";
if(illust_data.pageCount > 1 && this.displayed_page != null)
page_text = "Page " + (this.displayed_page+1) + "/" + illust_data.pageCount;
set_info(".page-count", page_text);
}
// Set the resolution to display in image info. If both are null, no resolution
// is displayed.
set_displayed_page_info(page)
{
this.displayed_page = page;
this.refresh();
}
clicked_download(e)
{
if(this.illust_data == null)
return;
var clicked_button = e.target.closest(".download-button");
if(clicked_button == null)
return;
e.preventDefault();
e.stopPropagation();
actions.download_illust(this.illust_data, this.progress_bar.controller());
}
}
// Handle showing the search history and tag edit dropdowns.
class tag_search_box_widget
{
constructor(container)
{
this.input_onfocus = this.input_onfocus.bind(this);
this.input_onblur = this.input_onblur.bind(this);
this.container_onmouseenter = this.container_onmouseenter.bind(this);
this.container_onmouseleave = this.container_onmouseleave.bind(this);
this.submit_search = this.submit_search.bind(this);
this.container = container;
this.input_element = this.container.querySelector(".search-tags");
this.dropdown_widget = new tag_search_dropdown_widget(container);
this.edit_widget = new tag_search_edit_widget(container);
this.container.addEventListener("mouseenter", this.container_onmouseenter);
this.container.addEventListener("mouseleave", this.container_onmouseleave);
this.input_element.addEventListener("focus", this.input_onfocus);
this.input_element.addEventListener("blur", this.input_onblur);
let edit_button = this.container.querySelector(".edit-search-button");
if(edit_button)
{
edit_button.addEventListener("click", (e) => {
// Toggle the edit widget, hiding the search history dropdown if it's shown.
if(this.dropdown_widget.shown)
this.hide();
if(this.edit_widget.shown)
this.hide();
else
this.show_edit();
});
}
// Search submission:
helpers.input_handler(this.input_element, this.submit_search);
this.container.querySelector(".search-submit-button").addEventListener("click", this.submit_search);
// Hide the dropdowns on navigation.
new view_hidden_listener(this.input_element, (e) => {
this.hide();
});
}
async show_history()
{
// Don't show history if search editing is already open.
if(this.edit_widget.shown)
return;
this.dropdown_widget.show();
}
show_edit()
{
// Don't show search editing if history is already open.
if(this.dropdown_widget.shown)
return;
this.edit_widget.show();
// Disable adding searches to search history while the edit dropdown is open. Otherwise,
// every time a tag is toggled, that combination of tags is added to search history by
// data_source_search, which makes a mess.
helpers.disable_adding_search_tags(true);
}
hide()
{
helpers.disable_adding_search_tags(false);
this.dropdown_widget.hide();
this.edit_widget.hide();
}
container_onmouseenter(e)
{
this.mouse_over_parent = true;
}
container_onmouseleave(e)
{
this.mouse_over_parent = false;
if(this.dropdown_widget.shown && !this.input_focused && !this.mouse_over_parent)
this.hide();
}
// Show the dropdown when the input is focused. Hide it when the input is both
// unfocused and this.container isn't being hovered. This way, the input focus
// can leave the input box to manipulate the dropdown without it being hidden,
// but we don't rely on hovering to keep the dropdown open.
input_onfocus(e)
{
this.input_focused = true;
if(!this.dropdown_widget.shown && !this.edit_widget.shown)
this.show_history();
}
input_onblur(e)
{
this.input_focused = false;
if(this.dropdown_widget.shown && !this.input_focused && !this.mouse_over_parent)
this.hide();
}
submit_search(e)
{
// This can be sent to either the search page search box or the one in the
// navigation dropdown. Figure out which one we're on.
var search_box = e.target.closest(".search-box");
var tags = this.input_element.value.trim();
if(tags.length == 0)
return;
// Add this tag to the recent search list.
helpers.add_recent_search_tag(tags);
// If we're submitting by pressing enter on an input element, unfocus it and
// close any widgets inside it (tag dropdowns).
if(e.target instanceof HTMLInputElement)
{
e.target.blur();
view_hidden_listener.send_viewhidden(e.target);
}
// Run the search.
helpers.set_page_url(page_manager.singleton().get_url_for_tag_search(tags), true);
}
}
class tag_search_dropdown_widget
{
constructor(container)
{
this.dropdown_onclick = this.dropdown_onclick.bind(this);
this.input_onkeydown = this.input_onkeydown.bind(this);
this.input_oninput = this.input_oninput.bind(this);
this.populate_dropdown = this.populate_dropdown.bind(this);
this.container = container;
this.input_element = this.container.querySelector(".search-tags");
this.input_element.addEventListener("keydown", this.input_onkeydown);
this.input_element.addEventListener("input", this.input_oninput);
// Refresh the dropdown when the tag search history changes.
window.addEventListener("recent-tag-searches-changed", this.populate_dropdown);
// Add the dropdown widget.
this.tag_dropdown = helpers.create_from_template(".template-tag-dropdown");
this.tag_dropdown.addEventListener("click", this.dropdown_onclick);
this.container.appendChild(this.tag_dropdown);
this.current_autocomplete_results = [];
// input-dropdown is resizable. Save the size when the user drags it.
this.input_dropdown = this.tag_dropdown.querySelector(".input-dropdown");
let observer = new MutationObserver((mutations) => {
// resize sets the width. Use this instead of offsetWidth, since offsetWidth sometimes reads
// as 0 here.
settings.set("tag-dropdown-width", this.input_dropdown.style.width);
});
observer.observe(this.input_dropdown, { attributes: true });
// Restore input-dropdown's width. Force a minimum width, in case this setting is saved incorrectly.
this.input_dropdown.style.width = settings.get("tag-dropdown-width", "400px");
this.shown = false;
this.tag_dropdown.hidden = true;
// Sometimes the popup closes when searches are clicked and sometimes they're not. Make sure
// we always close on navigation.
this.tag_dropdown.addEventListener("click", (e) => {
if(e.defaultPrevented)
return;
let a = e.target.closest("A");
if(a == null)
return;
this.input_element.blur();
this.hide();
});
}
dropdown_onclick(e)
{
var remove_entry = e.target.closest(".remove-history-entry");
if(remove_entry != null)
{
// Clicked X to remove a tag from history.
e.stopPropagation();
e.preventDefault();
var tag = e.target.closest(".entry").dataset.tag;
helpers.remove_recent_search_tag(tag);
return;
}
// Close the dropdown if the user clicks a tag (but not when clicking
// remove-history-entry).
if(e.target.closest(".tag"))
this.hide();
}
input_onkeydown(e)
{
// Only handle inputs when we're open.
if(this.tag_dropdown.hidden)
return;
switch(e.keyCode)
{
case 38: // up arrow
case 40: // down arrow
e.preventDefault();
e.stopImmediatePropagation();
this.move(e.keyCode == 40);
break;
}
}
input_oninput(e)
{
if(this.tag_dropdown.hidden)
return;
// Clear the selection on input.
this.set_selection(null);
// Update autocomplete when the text changes.
this.run_autocomplete();
}
async show()
{
if(this.shown)
return;
this.shown = true;
// Fill in the dropdown before displaying it. If hide() is called before this
// finishes this will return false, so stop.
if(!await this.populate_dropdown())
return;
this.tag_dropdown.hidden = false;
}
hide()
{
if(!this.shown)
return;
this.shown = false;
// If populate_dropdown is still running, cancel it.
this.cancel_populate_dropdown();
this.tag_dropdown.hidden = true;
// Make sure the input isn't focused.
this.input_element.blur();
}
async run_autocomplete()
{
// If true, this is a value change caused by keyboard navigation. Don't run autocomplete,
// since we don't want to change the dropdown due to navigating in it.
if(this.navigating)
return;
var tags = this.input_element.value.trim();
// Stop if we're already up to date.
if(this.most_recent_search == tags)
return;
if(this.autocomplete_request != null)
{
// If an autocomplete request is already running, let it finish before we
// start another. This matches the behavior of Pixiv's input forms.
console.log("Delaying search for", tags);
return;
}
if(tags == "")
{
// Don't send requests with an empty string. Just finish the search synchronously,
// so we clear the autocomplete immediately.
if(this.abort_autocomplete != null)
this.abort_autocomplete.abort();
this.autocomplete_request_finished("", { candidates: [] });
return;
}
// Run the search.
try {
this.abort_autocomplete = new AbortController();
var result = await helpers.rpc_get_request("/rpc/cps.php", {
keyword: tags,
}, {
signal: this.abort_autocomplete.signal,
});
this.autocomplete_request_finished(tags, result);
} catch(e) {
console.info("Tag autocomplete aborted:", e);
} finally {
this.abort_autocomplete = null;
}
}
// A tag autocomplete request finished.
autocomplete_request_finished(tags, result)
{
this.most_recent_search = tags;
this.abort_autocomplete = null;
// Store the new results.
this.current_autocomplete_results = result.candidates || [];
// Refresh the dropdown with the new results.
this.populate_dropdown();
// If the input element's value has changed since we started this search, we
// stalled any other autocompletion. Start it now.
if(tags != this.input_element.value)
{
console.log("Run delayed autocomplete");
this.run_autocomplete();
}
}
// tag_search is a search, like "tag -tag2". translated_tags is a dictionary of known translations.
create_entry(tag_search, translated_tags)
{
var entry = helpers.create_from_template(".template-tag-dropdown-entry");
entry.dataset.tag = tag_search;
let translated_tag = translated_tags[tag_search];
if(translated_tag)
entry.dataset.translated_tag = translated_tag;
let tag_container = entry.querySelector(".search");
for(let tag of helpers.split_search_tags(tag_search))
{
if(tag == "")
continue;
let span = document.createElement("span");
span.dataset.tag = tag;
span.classList.add("word");
if(tag != "or")
span.classList.add("tag");
// Split off - prefixes to look up the translation, then add it back.
let prefix_and_tag = helpers.split_tag_prefixes(tag);
let translated_tag = translated_tags[prefix_and_tag[1]];
if(translated_tag)
translated_tag = prefix_and_tag[0] + translated_tag;
span.innerText = translated_tag || tag;
if(translated_tag)
span.dataset.translated_tag = translated_tag;
tag_container.appendChild(span);
}
var url = page_manager.singleton().get_url_for_tag_search(tag_search);
entry.querySelector("A.search").href = url;
return entry;
}
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 = helpers.get_value("recent-tag-searches") || [];
// Separate tags in each search, so we can look up translations.
//
var all_tags = {};
for(let tag_search of tag_searches)
{
for(let tag of helpers.split_search_tags(tag_search))
{
tag = helpers.split_tag_prefixes(tag)[1];
all_tags[tag] = true;
}
}
all_tags = Object.keys(all_tags);
let translated_tags = await tag_translations.get().get_translations(all_tags, "en");
// Check if we were aborted while we were loading tags.
if(abort_signal && abort_signal.aborted)
{
console.log("populate_dropdown_inner aborted");
return false;
}
var list = this.tag_dropdown.querySelector(".input-dropdown-list");
helpers.remove_elements(list);
var autocompleted_tags = this.current_autocomplete_results;
for(var tag of autocompleted_tags)
{
var entry = this.create_entry(tag.tag_name, translated_tags);
entry.classList.add("autocomplete");
list.appendChild(entry);
}
for(var tag of tag_searches)
{
var entry = this.create_entry(tag, translated_tags);
entry.classList.add("history");
list.appendChild(entry);
}
return true;
}
cancel_populate_dropdown()
{
if(this.populate_dropdown_abort == null)
return;
this.populate_dropdown_abort.abort();
}
}
class tag_search_edit_widget
{
constructor(container)
{
this.dropdown_onclick = this.dropdown_onclick.bind(this);
this.populate_dropdown = this.populate_dropdown.bind(this);
this.container = container;
this.input_element = this.container.querySelector(".search-tags");
// Refresh the dropdown when the tag search history changes.
window.addEventListener("recent-tag-searches-changed", this.populate_dropdown);
// Add the dropdown widget.
this.tag_dropdown = helpers.create_from_template(".template-edit-search-dropdown");
this.tag_dropdown.addEventListener("click", this.dropdown_onclick);
this.container.appendChild(this.tag_dropdown);
// Refresh tags if the user edits the search directly.
this.input_element.addEventListener("input", (e) => { this.refresh_highlighted_tags(); });
// input-dropdown is resizable. Save the size when the user drags it.
this.input_dropdown = this.tag_dropdown.querySelector(".input-dropdown");
let observer = new MutationObserver((mutations) => {
// resize sets the width. Use this instead of offsetWidth, since offsetWidth sometimes reads
// as 0 here.
settings.set("search-edit-dropdown-width", this.input_dropdown.style.width);
});
observer.observe(this.input_dropdown, { attributes: true });
// Restore input-dropdown's width. Force a minimum width, in case this setting is saved incorrectly.
this.input_dropdown.style.width = settings.get("search-edit-dropdown-width", "400px");
this.shown = false;
this.tag_dropdown.hidden = true;
}
dropdown_onclick(e)
{
e.preventDefault();
e.stopImmediatePropagation();
// Clicking tags toggles the tag in the search box.
let tag = e.target.closest(".tag");
if(tag == null)
return;
this.toggle_tag(tag.dataset.tag);
// Control-clicking the tag probably caused its enclosing search link to be focused, which will
// cause it to activate when enter is pressed. Switch focus to the input box, so pressing enter
// will submit the search.
this.input_element.focus();
}
async show()
{
if(this.shown)
return;
this.shown = true;
// Fill in the dropdown before displaying it. If hide() is called before this
// finishes this will return false, so stop.
if(!await this.populate_dropdown())
return;
this.tag_dropdown.hidden = false;
}
hide()
{
if(!this.shown)
return;
this.shown = false;
// If populate_dropdown is still running, cancel it.
this.cancel_populate_dropdown();
this.tag_dropdown.hidden = true;
// Make sure the input isn't focused.
this.input_element.blur();
}
// tag_search is a search, like "tag -tag2". translated_tags is a dictionary of known translations.
create_entry(tag_search, translated_tags)
{
var entry = helpers.create_from_template(".template-edit-search-dropdown-entry");
entry.dataset.tag = tag_search;
let translated_tag = translated_tags[tag_search];
if(translated_tag)
entry.dataset.translated_tag = translated_tag;
let tag_container = entry.querySelector(".search");
for(let tag of helpers.split_search_tags(tag_search))
{
if(tag == "")
continue;
let span = document.createElement("span");
span.dataset.tag = tag;
span.classList.add("word");
if(tag != "or")
span.classList.add("tag");
// Split off - prefixes to look up the translation, then add it back.
let prefix_and_tag = helpers.split_tag_prefixes(tag);
let translated_tag = translated_tags[prefix_and_tag[1]];
if(translated_tag)
translated_tag = prefix_and_tag[0] + translated_tag;
span.innerText = translated_tag || tag;
if(translated_tag)
span.dataset.translated_tag = translated_tag;
tag_container.appendChild(span);
}
var url = page_manager.singleton().get_url_for_tag_search(tag_search);
entry.querySelector("A.search").href = url;
return entry;
}
// Populate the tag dropdown.
//
// This is async, since IndexedDB is async. (It shouldn't be. It's an overcorrection.
// Network APIs should be async, but local I/O should not be forced async.) If another
// call to populate_dropdown() is made before this completes or cancel_populate_dropdown
// cancels it, return false. If it completes, return true.
async populate_dropdown()
{
// If another populate_dropdown is already running, cancel it and restart.
this.cancel_populate_dropdown();
// Set populate_dropdown_abort to an AbortController for this call.
let abort_controller = this.populate_dropdown_abort = new AbortController();
let abort_signal = abort_controller.signal;
var tag_searches = helpers.get_value("recent-tag-searches") || [];
// Individually show all tags in search history.
var all_tags = {};
for(let tag_search of tag_searches)
{
for(let tag of helpers.split_search_tags(tag_search))
{
tag = helpers.split_tag_prefixes(tag)[1];
// Ignore "or".
if(tag == "" || tag == "or")
continue;
all_tags[tag] = true;
}
}
all_tags = Object.keys(all_tags);
let translated_tags = await tag_translations.get().get_translations(all_tags, "en");
// Sort tags by their translation.
all_tags.sort((lhs, rhs) => {
if(translated_tags[lhs]) lhs = translated_tags[lhs];
if(translated_tags[rhs]) rhs = translated_tags[rhs];
return lhs.localeCompare(rhs);
});
// Check if we were aborted while we were loading tags.
if(abort_signal && abort_signal.aborted)
{
console.log("populate_dropdown_inner aborted");
return false;
}
var list = this.tag_dropdown.querySelector(".input-dropdown-list");
helpers.remove_elements(list);
for(var tag of all_tags)
{
var entry = this.create_entry(tag, translated_tags);
list.appendChild(entry);
}
this.refresh_highlighted_tags();
return true;
}
cancel_populate_dropdown()
{
if(this.populate_dropdown_abort == null)
return;
this.populate_dropdown_abort.abort();
}
refresh_highlighted_tags()
{
let tags = helpers.split_search_tags(this.input_element.value);
var list = this.tag_dropdown.querySelector(".input-dropdown-list");
for(let tag_entry of list.querySelectorAll("[data-tag]"))
{
let tag = tag_entry.dataset.tag;
let tag_selected = tags.indexOf(tag) != -1;
helpers.set_class(tag_entry, "highlight", tag_selected);
}
}
// Add or remove tag from the tag search. This doesn't affect -tag searches.
toggle_tag(tag)
{
console.log("Toggle tag:", tag);
let tags = helpers.split_search_tags(this.input_element.value);
let idx = tags.indexOf(tag);
if(idx != -1)
tags.splice(idx, 1);
else
tags.push(tag);
this.input_element.value = tags.join(" ");
this.refresh_highlighted_tags();
// Navigate to the edited search immediately. Don't add these to history, since it
// spams navigation history.
helpers.set_page_url(page_manager.singleton().get_url_for_tag_search(this.input_element.value), false);
}
}
class tag_translations
{
// Return the singleton, creating it if needed.
static get()
{
if(tag_translations._singleton == null)
tag_translations._singleton = new tag_translations();
return tag_translations._singleton;
};
constructor()
{
this.db = new key_storage("pp_tag_translations");
}
// Store a list of tag translations.
//
// tag_list is a list of
// {
// tag: "original tag",
// translation: {
// en: "english tag",
// },
// }
//
// This is the same format that Pixiv uses in newer APIs. Note that we currently only store
// English translations.
async add_translations(tag_list)
{
let data = {};
for(let tag of tag_list)
{
// If a tag has no keys and no romanization, skip it so we don't fill our database
// with useless entries.
if((tag.translation == null || Object.keys(tag.translation).length == 0) && tag.romaji == null)
continue;
// Remove empty translation values.
let translation = {};
for(let lang of Object.keys(tag.translation || {}))
{
let value = tag.translation[lang];
if(value != "")
translation[lang] = value;
}
// Store the tag data that we care about. We don't need to store post-specific info
// like "deletable".
data[tag.tag] = {
tag: tag.tag,
translation: translation,
romaji: tag.romaji,
};
}
// Batch write:
await this.db.multi_set(data);
}
async get_tag_info(tags)
{
// If the user has disabled translations, don't return any.
if(settings.get("disable-translations"))
return {};
let result = {};
let translations = await this.db.multi_get(tags);
for(let i = 0; i < tags.length; ++i)
{
if(translations[i] == null)
continue;
result[tags[i]] = translations[i];
}
return result;
}
async get_translations(tags, language)
{
let info = await this.get_tag_info(tags);
let result = {};
for(let tag of tags)
{
if(info[tag] == null || info[tag].translation == null)
continue;
// Skip this tag if we don't have a translation for this language.
let translation = info[tag].translation[language];
if(translation == null)
continue;
result[tag] = translation;
}
return result;
}
// Given a tag search, return a translated search.
async translate_tag_list(tags, language)
{
// Pull out individual tags, removing -prefixes.
let split_tags = helpers.split_search_tags(tags);
let tag_list = [];
for(let tag of split_tags)
{
let prefix_and_tag = helpers.split_tag_prefixes(tag);
tag_list.push(prefix_and_tag[1]);
}
// Get translations.
let translated_tags = await this.get_translations(tag_list, language);
// Put the search back together.
let result = [];
for(let one_tag of split_tags)
{
let prefix_and_tag = helpers.split_tag_prefixes(one_tag);
let prefix = prefix_and_tag[0];
let tag = prefix_and_tag[1];
if(translated_tags[tag])
tag = translated_tags[tag];
result.push(prefix + tag);
}
return result.join(" ");
}
}
// This handles batch fetching data for thumbnails.
//
// We can load a bunch of images at once with illust_list.php. This isn't enough to
// display the illustration, since it's missing a lot of data, but it's enough for
// displaying thumbnails (which is what the page normally uses it for).
class thumbnail_data
{
constructor()
{
this.loaded_thumbnail_info = this.loaded_thumbnail_info.bind(this);
// Cached data:
this.thumbnail_data = { };
this.quick_user_data = { };
// IDs that we're currently requesting:
this.loading_ids = {};
};
// Return the singleton, creating it if needed.
static singleton()
{
if(thumbnail_data._singleton == null)
thumbnail_data._singleton = new thumbnail_data();
return thumbnail_data._singleton;
};
// Return true if all thumbs in illust_ids have been loaded, or are currently loading.
//
// We won't start fetching IDs that aren't loaded.
are_all_ids_loaded_or_loading(illust_ids)
{
for(var illust_id of illust_ids)
{
if(this.thumbnail_data[illust_id] == null && !this.loading_ids[illust_id])
return false;
}
return true;
}
// Return thumbnail data for illud_id, or null if it's not loaded.
//
// The thumbnail data won't be loaded if it's not already available. Use get_thumbnail_info
// to load thumbnail data in batches.
get_one_thumbnail_info(illust_id)
{
return this.thumbnail_data[illust_id];
}
// Return thumbnail data for illust_ids, and start loading any requested IDs that aren't
// already loaded.
get_thumbnail_info(illust_ids)
{
var result = {};
var needed_ids = [];
for(var illust_id of illust_ids)
{
var data = this.thumbnail_data[illust_id];
if(data == null)
{
// If this is a user:user_id instead of an illust ID, make sure we don't request it
// as an illust ID.
if(illust_id.indexOf(":") != -1)
continue;
needed_ids.push(illust_id);
continue;
}
result[illust_id] = data;
}
// Load any thumbnail data that we didn't have.
if(needed_ids.length)
this.load_thumbnail_info(needed_ids);
return result;
}
// Load thumbnail info for the given list of IDs.
async load_thumbnail_info(illust_ids)
{
// Make a list of IDs that we're not already loading.
var ids_to_load = [];
for(var id of illust_ids)
if(this.loading_ids[id] == null)
ids_to_load.push(id);
if(ids_to_load.length == 0)
return;
for(var id of ids_to_load)
this.loading_ids[id] = true;
// There's also
//
// https://www.pixiv.net/ajax/user/user_id/profile/illusts?ids[]=1&ids[]=2&...
//
// which is used by newer pages. That's useful since it tells us whether each
// image is bookmarked. However, it doesn't tell us the user's name or profile image
// URL, and for some reason it's limited to a particular user. Hopefully they'll
// have an updated generic illustration lookup call if they ever update the
// regular search pages, and we can switch to it then.
var result = await helpers.rpc_get_request("/rpc/illust_list.php", {
illust_ids: ids_to_load.join(","),
// Specifying this gives us 240x240 thumbs, which we want, rather than the 150x150
// ones we'll get if we don't (though changing the URL is easy enough too).
page: "discover",
// We do our own muting, but for some reason this flag is needed to get bookmark info.
exclude_muted_illusts: 1,
});
this.loaded_thumbnail_info(result, "illust_list");
}
// Get the mapping from /ajax/user/id/illusts/bookmarks to illust_list.php's keys.
get thumbnail_info_map_illust_list()
{
if(this._thumbnail_info_map_illust_list != null)
return this._thumbnail_info_map_illust_list;
this._thumbnail_info_map_illust_list = [
["illust_id", "id"],
["url", "url"],
["tags", "tags"],
["illust_user_id", "userId"],
["illust_width", "width"],
["illust_height", "height"],
["illust_type", "illustType"],
["illust_page_count", "pageCount"],
["illust_title", "title"],
["user_profile_img", "profileImageUrl"],
["user_name", "userName"],
];
return this._thumbnail_info_map_illust_list;
};
// Get the mapping from search.php and bookmark_new_illust.php to illust_list.php's keys.
get thumbnail_info_map_following()
{
if(this._thumbnail_info_map_following != null)
return this._thumbnail_info_map_following;
this._thumbnail_info_map_following = [
["illustId", "id"],
["url", "url"],
["tags", "tags"],
["userId", "userId"],
["width", "width"],
["height", "height"],
["pageCount", "pageCount"],
["illustTitle", "title"],
["userName", "userName"],
["illustType", "illustType"],
// ["user_profile_img", "profileImageUrl"],
];
return this._thumbnail_info_map_following;
};
get thumbnail_info_map_ranking()
{
if(this._thumbnail_info_map_ranking != null)
return this._thumbnail_info_map_ranking;
this._thumbnail_info_map_ranking = [
["illust_id", "id"],
["url", "url"],
["tags", "tags"],
["user_id", "userId"],
["width", "width"],
["height", "height"],
["illust_page_count", "pageCount"],
["title", "title"],
["user_name", "userName"],
["illust_type", "illustType"],
// ["profile_img", "profileImageUrl"],
];
return this._thumbnail_info_map_ranking;
};
// Given a low-res thumbnail URL from thumbnail data, return a high-res thumbnail URL.
get_high_res_thumbnail_url(url)
{
// Some random results on the user recommendations page also return this:
//
// /c/540x540_70/custom-thumb/img/.../12345678_custom1200.jpg
//
// Replace /custom-thumb/' with /img-master/ first, since it makes matching below simpler.
url = url.replace("/custom-thumb/", "/img-master/");
// path should look like
//
// /c/250x250_80_a2/img-master/img/.../12345678_square1200.jpg
//
// where 250x250_80_a2 is the resolution and probably JPEG quality. We want
// the higher-res thumbnail (which is "small" in the full image data), which
// looks like:
//
// /c/540x540_70/img-master/img/.../12345678_master1200.jpg
//
// The resolution field is changed, and "square1200" is changed to "master1200".
var url = new URL(url, document.location);
var path = url.pathname;
var re = /(\/c\/)([^\/]+)(.*)(square1200|master1200|custom1200).jpg/;
var match = re.exec(path);
if(match == null)
{
console.warn("Couldn't parse thumbnail URL:", path);
return url.toString();
}
url.pathname = match[1] + "540x540_70" + match[3] + "master1200.jpg";
return url.toString();
}
// This is called when we have new thumbnail data available. thumb_result is
// an array of thumbnail items.
//
// This can come from a bunch of different places, which all return the same data, but
// each in a different way:
//
// name URL
// normal /ajax/user/id/illusts/bookmarks
// illust_list illust_list.php
// following bookmark_new_illust.php
// following search.php
// rankings ranking.php
//
// We map each of these to "normal".
//
// These have the same data, but for some reason everything has different names.
// Remap them to "normal", and check that all fields we expect exist, to make it
// easier to notice if something is wrong.
loaded_thumbnail_info(thumb_result, source)
{
if(thumb_result.error)
return;
var thumbnail_info_map = this.thumbnail_info_map_illust_list;
var urls = [];
for(var thumb_info of thumb_result)
{
// Ignore entries with "isAdContainer". These aren't search results at all and just contain
// stuff we're not interested in.
if(thumb_info.isAdContainer)
continue;
if(source == "normal")
{
// The data is already in the format we want. Just check that all keys we
// expect exist, and remove any keys we don't know about so we don't use them
// accidentally.
var thumbnail_info_map = this.thumbnail_info_map_illust_list;
var remapped_thumb_info = { };
for(var pair of thumbnail_info_map)
{
var key = pair[1];
if(!(key in thumb_info))
{
console.warn("Thumbnail info is missing key:", key);
continue;
}
remapped_thumb_info[key] = thumb_info[key];
}
if(!('bookmarkData' in thumb_info))
console.warn("Thumbnail info is missing key: bookmarkData");
else
{
remapped_thumb_info.bookmarkData = thumb_info.bookmarkData;
// See above.
if(remapped_thumb_info.bookmarkData != null)
delete remapped_thumb_info.bookmarkData.bookmarkId;
}
}
else if(source == "illust_list" || source == "following" || source == "rankings" ||
source == "illust_new" || source == "search")
{
// Get the mapping for this mode.
var thumbnail_info_map =
source == "illust_list"? this.thumbnail_info_map_illust_list:
source == "following" || source == "illust_new" || source == "search"? this.thumbnail_info_map_following:
this.thumbnail_info_map_ranking;
var remapped_thumb_info = { };
for(var pair of thumbnail_info_map)
{
var from_key = pair[0];
var to_key = pair[1];
if(!(from_key in thumb_info))
{
console.warn("Thumbnail info is missing key:", from_key);
continue;
}
var value = thumb_info[from_key];
remapped_thumb_info[to_key] = value;
}
// Make sure that the illust IDs and user IDs are strings.
remapped_thumb_info.id = "" + remapped_thumb_info.id;
remapped_thumb_info.userId = "" + remapped_thumb_info.userId;
// Bookmark data is a special case.
//
// The old API has is_bookmarked: true, bookmark_id: "id" and bookmark_illust_restrict: 0 or 1.
// bookmark_id and bookmark_illust_restrict are omitted if is_bookmarked is false.
//
// The new API is a dictionary:
//
// bookmarkData = {
// bookmarkId: id,
// private: false
// }
//
// or null if not bookmarked.
//
// A couple sources of thumbnail data (bookmark_new_illust.php and search.php)
// don't return the bookmark ID. We don't use this (we only edit bookmarks from
// the image page, where we have full image data), so we omit bookmarkId from this
// data.
//
// Some pages return buggy results. /ajax/user/id/profile/all includes bookmarkData,
// but private is always false, so we can't tell if it's a private bookmark. This is
// a site bug that we can't do anything about (it affects the site too).
remapped_thumb_info.bookmarkData = null;
if(source == "illust_list" || source == "rankings")
{
if(!('is_bookmarked' in thumb_info))
console.warn("Thumbnail info is missing key: is_bookmarked");
if(thumb_info.is_bookmarked)
{
remapped_thumb_info.bookmarkData = {
// See above.
// bookmarkId: thumb_info.bookmark_id,
private: thumb_info.bookmark_illust_restrict == 1,
};
}
}
else if(source == "following")
{
// Why are there fifteen API variants for everything? It's as if they
// hire a contractor for every feature and nobody ever talks to each other,
// so every feature has its own new API layout.
if(!('isBookmarked' in thumb_info))
console.warn("Thumbnail info is missing key: isBookmarked");
if(thumb_info.isBookmarked)
{
remapped_thumb_info.bookmarkData = {
private: thumb_info.isPrivateBookmark,
};
}
}
else if(source == "search")
remapped_thumb_info.bookmarkData = thumb_info.bookmarkData;
// illustType can be a string in these instead of an int, so convert it.
remapped_thumb_info.illustType = parseInt(remapped_thumb_info.illustType);
// Some of these APIs don't provide the user's avatar URL. We only use it in a blurred-
// out thumbnail for muted images, so just drop in the "no avatar" image.
if(remapped_thumb_info.profileImageUrl == null)
remapped_thumb_info.profileImageUrl = "https://s.pximg.net/common/images/no_profile_s.png";
}
else
throw "Unrecognized source: " + source;
// Different APIs return different thumbnail URLs.
remapped_thumb_info.url = this.get_high_res_thumbnail_url(remapped_thumb_info.url);
// These fields are strings in some sources. Switch them to ints.
for(let key of ["pageCount", "width", "height"])
{
if(remapped_thumb_info[key] != null)
remapped_thumb_info[key] = parseInt(remapped_thumb_info[key]);
}
thumb_info = remapped_thumb_info;
// Store the data.
this.add_thumbnail_info(thumb_info);
var illust_id = thumb_info.id;
delete this.loading_ids[illust_id];
// Don't preload muted images.
if(!this.is_muted(thumb_info))
urls.push(thumb_info.url);
}
// Broadcast that we have new thumbnail data available.
window.dispatchEvent(new Event("thumbnailsLoaded"));
};
// Store thumbnail info.
add_thumbnail_info(thumb_info)
{
var illust_id = thumb_info.id;
this.thumbnail_data[illust_id] = thumb_info;
}
is_muted(thumb_info)
{
if(muting.singleton.is_muted_user_id(thumb_info.illust_user_id))
return true;
if(muting.singleton.any_tag_muted(thumb_info.tags))
return true;
return false;
}
// This is a simpler form of thumbnail data for user info. This is just the bare minimum
// info we need to be able to show a user thumbnail on the search page.
//
// We can get this info from two places, the following page (data_source_follows) and the
// user recommendations page (data_source_discovery_users). Of course, since Pixiv never
// does anything the same way twice, they have different formats.
//
// The only info we need is:
// userId
// userName
// profileImageUrl
add_quick_user_data(user_data, source)
{
let data = null;
if(source == "following")
{
data = {
userId: user_data.userId,
userName: user_data.userName,
profileImageUrl: user_data.profileImageUrl,
};
}
else if(source == "recommendations")
{
data = {
userId: user_data.user_id,
userName: user_data.user_name,
profileImageUrl: user_data.profile_img,
};
}
else
throw "Unknown source: " + source;
this.quick_user_data[data.userId] = data;
}
get_quick_user_data(user_id)
{
return this.quick_user_data[user_id];
}
}
class scroll_handler
{
constructor(container)
{
this.container = container;
}
// Bring item into view. We'll also try to keep the next and previous items visible.
scroll_into_view(item)
{
// Make sure item is a direct child of the container.
if(item.parentNode != this.container)
{
console.error("Node", item, "isn't in scroller", this.container);
return;
}
// Scroll so the items to the left and right of the current thumbnail are visible,
// so you can tell whether there's another entry to scroll to. If we can't fit
// them, center the selection.
var scroller_left = this.container.getBoundingClientRect().left;
var left = item.offsetLeft - scroller_left;
if(item.previousElementSibling)
left = Math.min(left, item.previousElementSibling.offsetLeft - scroller_left);
var right = item.offsetLeft + item.offsetWidth - scroller_left;
if(item.nextElementSibling)
right = Math.max(right, item.nextElementSibling.offsetLeft + item.nextElementSibling.offsetWidth - scroller_left);
var new_left = this.container.scrollLeft;
if(new_left > left)
new_left = left;
if(new_left + this.container.offsetWidth < right)
new_left = right - this.container.offsetWidth;
this.container.scrollLeft = new_left;
// If we didn't fit the previous and next entries, there isn't enough space. This
// might be a wide thumbnail or the window might be very narrow. Just center the
// selection. Note that we need to compare against the value we assigned and not
// read scrollLeft back, since the API is broken and reads back the smoothed value
// rather than the target we set.
if(new_left > left ||
new_left + this.container.offsetWidth < right)
{
this.center_item(item);
}
}
// Scroll the given item to the center.
center_item(item)
{
var scroller_left = this.container.getBoundingClientRect().left;
var left = item.offsetLeft - scroller_left;
left += item.offsetWidth/2;
left -= this.container.offsetWidth / 2;
this.container.scrollLeft = left;
}
/* Snap to the target position, cancelling any smooth scrolling. */
snap()
{
this.container.style.scrollBehavior = "auto";
if(this.container.firstElementChild)
this.container.firstElementChild.getBoundingClientRect();
this.container.getBoundingClientRect();
this.container.style.scrollBehavior = "";
}
};
class manga_thumbnail_widget
{
constructor(container)
{
this.onclick = this.onclick.bind(this);
this.onmouseenter = this.onmouseenter.bind(this);
this.onmouseleave = this.onmouseleave.bind(this);
this.check_image_loads = this.check_image_loads.bind(this);
this.window_onresize = this.window_onresize.bind(this);
window.addEventListener("resize", this.window_onresize);
this.container = container;
this.container.addEventListener("click", this.onclick);
this.container.addEventListener("mouseenter", this.onmouseenter);
this.container.addEventListener("mouseleave", this.onmouseleave);
this.cursor = document.createElement("div");
this.cursor.classList.add("thumb-list-cursor");
this.scroll_box = this.container.querySelector(".manga-thumbnails");
this.scroller = new scroll_handler(this.scroll_box);
this.visible = false;
this.set_illust_info(null);
}
// Both Firefox and Chrome have some nasty layout bugs when resizing the window,
// causing the flexbox and the images inside it to be incorrect. Work around it
// by forcing a refresh.
window_onresize(e)
{
this.refresh();
}
onmouseenter(e)
{
this.hovering = true;
this.refresh_visible();
}
onmouseleave(e)
{
this.stop_hovering();
}
stop_hovering()
{
this.hovering = false;
this.refresh_visible();
}
refresh_visible()
{
this.visible = this.hovering;
}
get visible()
{
return this.container.classList.contains("visible");
}
set visible(visible)
{
if(visible == this.visible)
return;
helpers.set_class(this.container, "visible", visible);
if(!visible)
this.stop_hovering();
}
onclick(e)
{
var arrow = e.target.closest(".manga-thumbnail-arrow");
if(arrow != null)
{
e.preventDefault();
e.stopPropagation();
var left = arrow.dataset.direction == "left";
console.log("scroll", left);
var new_page = this.current_page + (left? -1:+1);
if(new_page < 0 || new_page >= this.entries.length)
return;
main_controller.singleton.show_illust(this.illust_info.illustId, {
manga_page: new_page,
});
/*
var entry = this.entries[new_page];
if(entry == null)
return;
this.scroller.scroll_into_view(entry);
*/
return;
}
var thumb = e.target.closest(".manga-thumbnail-box");
if(thumb != null)
{
e.preventDefault();
e.stopPropagation();
var new_page = parseInt(thumb.dataset.page);
main_controller.singleton.show_illust(this.illust_info.illustId, {
manga_page: new_page,
});
return;
}
}
set_illust_info(illust_info)
{
if(illust_info == this.illust_info)
return;
// Only display if we have at least two pages.
if(illust_info != null && illust_info.pageCount < 2)
illust_info = null;
// If we're not on a manga page, hide ourselves entirely, including the hover box.
this.container.hidden = illust_info == null;
this.illust_info = illust_info;
if(illust_info == null)
this.stop_hovering();
// Refresh the thumb images.
this.refresh();
// Start or stop check_image_loads if needed.
if(this.illust_info == null && this.check_image_loads_timer != null)
{
clearTimeout(this.check_image_loads_timer);
this.check_image_loads_timer = null;
}
this.check_image_loads();
}
snap_transition()
{
this.scroller.snap();
}
// This is called when the manga page is changed externally.
current_page_changed(page)
{
// Ignore page changes if we're not displaying anything.
if(this.illust_info == null)
return
this.current_page = page;
if(this.current_page == null)
return;
// Find the entry for the page.
var entry = this.entries[this.current_page];
if(entry == null)
{
console.error("Scrolled to unknown page", this.current_page);
return;
}
this.scroller.scroll_into_view(entry);
if(this.selected_entry)
helpers.set_class(this.selected_entry, "selected", false);
this.selected_entry = entry;
if(this.selected_entry)
{
helpers.set_class(this.selected_entry, "selected", true);
this.update_cursor_position();
}
}
update_cursor_position()
{
// Wait for images to know their size before positioning the cursor.
if(this.selected_entry == null || this.waiting_for_images || this.cursor.parentNode == null)
return;
// Position the cursor to the position of the selection.
this.cursor.style.width = this.selected_entry.offsetWidth + "px";
var scroller_left = this.scroll_box.getBoundingClientRect().left;
var base_left = this.cursor.parentNode.getBoundingClientRect().left;
var position_left = this.selected_entry.getBoundingClientRect().left;
var left = position_left - base_left;
this.cursor.style.left = left + "px";
}
// We can't update the UI properly until we know the size the thumbs will be,
// and the site doesn't tell us the size of manga pages (only the first page).
// Work around this by hiding until we have naturalWidth for all images, which
// will allow layout to complete. There's no event for this for some reason,
// so the only way to detect it is with a timer.
//
// This often isn't needed because of image preloading.
check_image_loads()
{
if(this.illust_info == null)
return;
this.check_image_loads_timer = null;
var all_images_loaded = true;
for(var img of this.container.querySelectorAll("img.manga-thumb"))
{
if(img.naturalWidth == 0)
all_images_loaded = false;
}
// If all images haven't loaded yet, check again.
if(!all_images_loaded)
{
this.waiting_for_images = true;
this.check_image_loads_timer = setTimeout(this.check_image_loads, 10);
return;
}
this.waiting_for_images = false;
// Now that we know image sizes and layout can update properly, we can update the cursor's position.
this.update_cursor_position();
}
refresh()
{
if(this.cursor.parentNode)
this.cursor.parentNode.removeChild(this.cursor);
var ul = this.container.querySelector(".manga-thumbnails");
helpers.remove_elements(ul);
this.entries = [];
if(this.illust_info == null)
return;
// Add left and right padding elements to center the list if needed.
var left_padding = document.createElement("div");
left_padding.style.flex = "1";
ul.appendChild(left_padding);
for(var page = 0; page < this.illust_info.pageCount; ++page)
{
var page_info = this.illust_info.mangaPages[page];
var url = page_info.urls.small;
var img = document.createElement("img");
var entry = helpers.create_from_template(".template-manga-thumbnail");
entry.dataset.page = page;
entry.querySelector("img.manga-thumb").src = url;
ul.appendChild(entry);
this.entries.push(entry);
}
var right_padding = document.createElement("div");
right_padding.style.flex = "1";
ul.appendChild(right_padding);
// Place the cursor inside the first entry, so it follows it around as we scroll.
this.entries[0].appendChild(this.cursor);
this.update_cursor_position();
}
};
// This handles:
//
// - Keeping track of whether we're active or not. If we're inactive, we turn off
// and let the page run normally.
// - Storing state in the address bar.
//
// We're active by default on illustration pages, and inactive by default on others.
//
// If we're active, we'll store our state in the hash as "#ppixiv/...". The start of
// the hash will always be "#ppixiv", so we can tell it's our data. If we're on a page
// where we're inactive by default, this also remembers that we've been activated.
//
// If we're inactive on a page where we're active by default, we'll always put something
// other than "#ppixiv" in the address bar. It doesn't matter what it is. This remembers
// that we were deactivated, and remains deactivated even if the user clicks an anchor
// in the page that changes the hash.
//
// If we become active or inactive after the page loads, we refresh the page.
//
// We have two sets of query parameters: args stored in the URL query, and args stored in
// the hash. For example, in:
//
// https://www.pixiv.net/bookmark.php?p=2#ppixiv?illust_id=1234
//
// our query args are p=2, and our hash args are illust_id=1234. We use query args to
// store state that exists in the underlying page, and hash args to store state that
// doesn't, so the URL remains valid for the actual Pixiv page if our UI is turned off.
class page_manager
{
constructor()
{
this.window_popstate = this.window_popstate.bind(this);
window.addEventListener("popstate", this.window_popstate, true);
this.data_sources_by_canonical_url = {};
this.active = this._active_internal();
};
// Return the singleton, creating it if needed.
static singleton()
{
if(page_manager._singleton == null)
page_manager._singleton = new page_manager();
return page_manager._singleton;
};
// Return the data source for a URL, or null if the page isn't supported.
get_data_source_for_url(url)
{
// url is usually document.location, which for some reason doesn't have .searchParams.
var url = new unsafeWindow.URL(url);
url = helpers.get_url_without_language(url);
let first_part = helpers.get_page_type_from_url(url);
if(first_part == "artworks")
return data_source_current_illust;
else if(url.pathname == "/member.php" && url.searchParams.get("id") != null)
return data_source_artist;
else if(url.pathname == "/member_illust.php" && url.searchParams.get("id") != null)
return data_source_artist;
else if(first_part == "users")
return data_source_artist;
else if(url.pathname == "/bookmark.php" && url.searchParams.get("type") == null)
{
// Handle a special case: we're called by early_controller just to find out if
// the current page is supported or not. This happens before window.global_data
// exists, so we can't check if we're viewing our own bookmarks or someone else's.
// In this case we don't need to, since the caller just wants to see if we return
// a data source or not.
if(window.global_data == null)
return data_source_bookmarks;
// If show-all=0 isn't in the hash, and we're not viewing someone else's bookmarks,
// we're viewing all bookmarks, so use data_source_bookmarks_merged. Otherwise,
// use data_source_bookmarks.
var hash_args = helpers.get_hash_args(url);
var query_args = url.searchParams;
var user_id = query_args.get("id");
if(user_id == null)
user_id = window.global_data.user_id;
var viewing_own_bookmarks = user_id == window.global_data.user_id;
var both_public_and_private = viewing_own_bookmarks && hash_args.get("show-all") != "0";
return both_public_and_private? data_source_bookmarks_merged:data_source_bookmarks;
}
else if(url.pathname == "/bookmark.php" && url.searchParams.get("type") == "user")
return data_source_follows;
else if(url.pathname == "/new_illust.php" || url.pathname == "/new_illust_r18.php")
return data_source_new_illust;
else if(url.pathname == "/bookmark_new_illust.php")
return data_source_bookmarks_new_illust;
else if(first_part == "tags")
return data_source_search;
else if(url.pathname == "/discovery")
return data_source_discovery;
else if(url.pathname == "/discovery/users")
return data_source_discovery_users;
else if(url.pathname == "/bookmark_detail.php")
return data_source_related_illusts;
else if(url.pathname == "/ranking.php")
return data_source_rankings;
else
return null;
};
// Create the data source for a given URL.
//
// If we've already created a data source for this URL, the same one will be
// returned.
//
// If force is true, we'll always create a new data source, replacing any
// previously created one.
async create_data_source_for_url(url, doc, force)
{
var data_source_class = this.get_data_source_for_url(url);
if(data_source_class == null)
{
console.error("Unexpected path:", url.pathname);
return;
}
// Canonicalize the URL to see if we already have a data source for this URL.
var canonical_url = await data_source_class.get_canonical_url(url);
// console.log("url", url.toString(), "becomes", canonical_url);
if(!force && canonical_url in this.data_sources_by_canonical_url)
{
// console.log("Reusing data source for", url.toString());
return this.data_sources_by_canonical_url[canonical_url];
}
// console.log("Creating new data source for", url.toString());
var source = new data_source_class(url.href, doc);
this.data_sources_by_canonical_url[canonical_url] = source;
return source;
}
// Return true if it's possible for us to be active on this page.
available()
{
// We support the page if it has a data source.
return this.get_data_source_for_url(document.location) != null;
};
window_popstate(e)
{
var currently_active = this._active_internal();
if(this.active == currently_active)
return;
// Stop propagation, so other listeners don't see this. For example, this prevents
// the thumbnail viewer from turning on or off as a result of us changing the hash
// to "#no-ppixiv".
e.stopImmediatePropagation();
if(this.active == currently_active)
return;
this.store_ppixiv_disabled(!currently_active);
console.log("Active state changed");
// The URL has changed and caused us to want to activate or deactivate. Reload the
// page.
//
// We'd prefer to reload with cache, like a regular navigation, but Firefox seems
// to reload without cache no matter what we do, even though document.location.reload
// is only supposed to bypass cache on reload(true). There doesn't seem to be any
// reliable workaround.
document.location.reload();
}
store_ppixiv_disabled(disabled)
{
// Remember that we're enabled or disabled in this tab.
if(disabled)
window.sessionStorage.ppixiv_disabled = 1;
else
delete window.sessionStorage.ppixiv_disabled;
}
// Return true if we're active by default on the current page.
active_by_default()
{
// If the disabled-by-default setting is enabled, disable by default until manually
// turned on.
if(settings.get("disabled-by-default"))
return false;
// If this is set, the user clicked the "return to Pixiv" button. Stay disabled
// in this tab until we're reactivated.
if(window.sessionStorage.ppixiv_disabled)
return false;
return this.available();
};
// Return true if we're currently active.
//
// This is cached at the start of the page and doesn't change unless the page is reloaded.
_active_internal()
{
// If the hash is empty, use the default.
if(document.location.hash == "")
return this.active_by_default();
// If we have a hash and it's not #ppixiv, then we're explicitly disabled. If we
// # do have a #ppixiv hash, we're explicitly enabled.
//
// If we're explicitly enabled but aren't actually available, we're disabled. This
// makes sure we don't break pages if we accidentally load them with a #ppixiv hash,
// or if we remove support for a page that people have in their browser session.
return helpers.parse_hash(document.location) != null && this.available();
};
// Given a list of tags, return the URL to use to search for them. This differs
// depending on the current page.
get_url_for_tag_search(tags)
{
let url = new URL(document.location);
url = helpers.get_url_without_language(url);
let type = helpers.get_page_type_from_url(url);
if(type == "tags")
{
// If we're on search already, just change the search tag, so we preserve other settings.
// /tags/tag/artworks -> /tag/new tag/artworks
let parts = url.pathname.split("/");
parts[2] = encodeURIComponent(tags);
url.pathname = parts.join("/");
} else {
// If we're not, change to search and remove the rest of the URL.
url = new URL("/tags/" + encodeURIComponent(tags) + "#ppixiv", document.location);
}
return url;
}
}
// Fix Pixiv's annoying link interstitials.
//
// External links on Pixiv go through a pointless extra page. This seems like
// they're trying to mask the page the user is coming from, but that's what
// rel=noreferrer is for. Search for these links and fix them.
//
// This also removes target=_blank, which is just obnoxious. If I want a new
// tab I'll middle click.
(function() {
// Ignore iframes.
if(window.top != window.self)
return;
var observer = new window.MutationObserver(function(mutations) {
for(var mutation of mutations) {
if(mutation.type != 'childList')
return;
for(var node of mutation.addedNodes)
{
if(node.querySelectorAll == null)
continue;
helpers.fix_pixiv_links(node);
}
}
});
window.addEventListener("DOMContentLoaded", function() {
helpers.fix_pixiv_links(document.body);
observer.observe(window.document.body, {
// We could listen to attribute changes so we'll fix links that have their
// target changed after they're added to the page, but unless there are places
// where that's needed, let's just listen to node additions so we don't trigger
// too often.
attributes: false,
childList: true,
subtree: true
});
}, true);
})();
// Handle preloading images.
//
// If we have a reasonably fast connection and the site is keeping up, we can just preload
// blindly and let the browser figure out priorities. However, if we preload too aggressively
// for the connection and loads start to back up, it can cause image loading to become delayed.
// For example, if we preload 100 manga page images, and then back out of the page and want to
// view something else, the browser won't load anything else until those images that we no
// longer need finish loading.
//
// image_preloader is told the illust_id that we're currently showing, and the ID that we want
// to speculatively load. We'll run loads in parallel, giving the current image's resources
// priority and cancelling loads when they're no longer needed.
//
// This doesn't handle thumbnail preloading. Those are small and don't really need to be
// cancelled, and since we don't fill the browser's load queue here, we shouldn't prevent
// thumbnails from being able to load.
// A base class for fetching a single resource:
class _preloader
{
// Cancel the fetch.
cancel()
{
if(this.abort_controller == null)
return;
this.abort_controller.abort();
this.abort_controller = null;
}
}
// Load a single image with
:
class _img_preloader extends _preloader
{
constructor(url)
{
super();
this.url = url;
}
// Start the fetch. This should only be called once.
async start()
{
this.abort_controller = new AbortController();
await helpers.decode_image(this.url, this.abort_controller.signal);
}
}
// Load a resource with XHR. We rely on helpers.fetch_resource to make concurrent
// loads with zip_image_player work cleanly.
class _xhr_preloader extends _preloader
{
constructor(url)
{
super();
this.url = url;
}
async start()
{
this.abort_controller = new AbortController();
await helpers.fetch_resource(this.url, {
signal: this.abort_controller.signal,
});
}
}
// The image preloader singleton.
class image_preloader
{
// Return the singleton, creating it if needed.
static get singleton()
{
if(image_preloader._singleton == null)
image_preloader._singleton = new image_preloader();
return image_preloader._singleton;
};
constructor()
{
// The _preloader objects that we're currently running.
this.preloads = [];
// A queue of URLs that we've finished preloading recently. We use this to tell if
// we don't need to run a preload.
this.recently_preloaded_urls = [];
}
// Set the illust_id the user is currently viewing. If illust_id is null, the user isn't
// viewing an image (eg. currently viewing thumbnails).
async set_current_image(illust_id)
{
if(this.current_illust_id == illust_id)
return;
this.current_illust_id = illust_id;
this.current_illust_info = null;
if(this.current_illust_id == null)
return;
// Get the image data. This will often already be available.
var illust_info = await image_data.singleton().get_image_info(this.current_illust_id);
if(this.current_illust_id != illust_id || this.current_illust_info != null)
return;
// Store the illust_info for current_illust_id.
this.current_illust_info = illust_info;
// Preload thumbnails.
this.preload_thumbs(illust_info);
this.check_fetch_queue();
}
// Set the illust_id we want to speculatively load, which is the next or previous image in
// the current search. If illust_id is null, we don't want to speculatively load anything.
async set_speculative_image(illust_id)
{
if(this.speculative_illust_id == illust_id)
return;
this.speculative_illust_id = illust_id;
this.speculative_illust_info = null;
if(this.speculative_illust_id == null)
return;
// Get the image data. This will often already be available.
var illust_info = await image_data.singleton().get_image_info(this.speculative_illust_id);
if(this.speculative_illust_id != illust_id || this.speculative_illust_info != null)
return;
// Store the illust_info for current_illust_id.
this.speculative_illust_info = illust_info;
// Preload thumbnails.
this.preload_thumbs(illust_info);
this.check_fetch_queue();
}
// See if we need to start or stop preloads. We do this when we have new illustration info,
// and when a fetch finishes.
check_fetch_queue()
{
// console.log("check queue:", this.current_illust_info != null, this.speculative_illust_info != null);
// Make a list of fetches that we want to be running, in priority order.
var wanted_preloads = [];
if(this.current_illust_info != null)
wanted_preloads = wanted_preloads.concat(this.create_preloaders_for_illust(this.current_illust_info));
if(this.speculative_illust_info != null)
wanted_preloads = wanted_preloads.concat(this.create_preloaders_for_illust(this.speculative_illust_info));
// Remove all preloads from wanted_preloads that we've already finished recently.
var filtered_preloads = [];
for(var preload of wanted_preloads)
{
if(this.recently_preloaded_urls.indexOf(preload.url) == -1)
filtered_preloads.push(preload);
}
// If we don't want any preloads, stop. If we have any running preloads, let them continue.
if(filtered_preloads.length == 0)
{
// console.log("Nothing to do");
return;
}
// Discard preloads beyond the number we want to be running. If we're loading more than this,
// we'll start more as these finish.
var concurrent_preloads = 5;
filtered_preloads.splice(concurrent_preloads);
// console.log("Preloads:", filtered_preloads.length);
// If any preload in the list is running, stop. We only run one preload at a time, so just
// let it finish.
let any_preload_running = false;
for(let preload of filtered_preloads)
{
let active_preload = this._find_active_preload_by_url(preload.url);
if(active_preload != null)
return;
}
// No preloads are running, so start the highest-priority preload.
//
// updated_preload_list allows us to run multiple preloads at a time, but we currently
// run them in serial.
var unwanted_preloads;
var updated_preload_list = [];
for(let preload of filtered_preloads)
{
// Start this preload.
// console.log("Start preload:", preload.url);
preload.start().finally(() => {
// Add the URL to recently_preloaded_urls, so we don't try to preload this
// again for a while. We do this even on error, so we don't try to load
// failing images repeatedly.
this.recently_preloaded_urls.push(preload.url);
this.recently_preloaded_urls.splice(0, this.recently_preloaded_urls.length - 1000);
// When the preload finishes (successful or not), remove it from the list.
var idx = this.preloads.indexOf(preload);
if(idx == -1)
{
console.error("Preload finished, but we weren't running it:", preload.url);
return;
}
this.preloads.splice(idx, 1);
// See if we need to start another preload.
this.check_fetch_queue();
});
updated_preload_list.push(preload);
break;
}
// Cancel preloads in this.preloads that aren't in updated_preload_list. These are
// preloads that we either don't want anymore, or which have been pushed further down
// the priority queue and overridden.
for(var preload of this.preloads)
{
if(updated_preload_list.indexOf(preload) != -1)
continue;
console.log("Cancelling preload:", preload.url);
preload.cancel();
// Preloads stay in the list until the cancellation completes.
updated_preload_list.push(preload);
}
this.preloads = updated_preload_list;
}
// Return the preloader if we're currently preloading url.
_find_active_preload_by_url(url)
{
for(var preload of this.preloads)
if(preload.url == url)
return preload;
return null;
}
// Return an array of preloaders to load resources for the given illustration.
create_preloaders_for_illust(illust_data)
{
// Don't precache muted images.
if(muting.singleton.any_tag_muted(illust_data.tags.tags))
return [];
if(muting.singleton.is_muted_user_id(illust_data.userId))
return [];
// If this is a video, preload the ZIP.
if(illust_data.illustType == 2)
{
var results = [];
results.push(new _xhr_preloader(illust_data.ugoiraMetadata.originalSrc));
// Preload the original image too, which viewer_ugoira displays if the ZIP isn't
// ready yet.
results.push(new _img_preloader(illust_data.urls.original));
return results;
}
// Otherwise, preload the images. Preload thumbs first, since they'll load
// much faster. Only preload low-res images for image viewing if low res previews
// are enabled.
var results = [];
for(var page of illust_data.mangaPages)
results.push(new _img_preloader(page.urls.small));
// Only preload the first page, which is the main page of a regular illustration.
// This also forces us to wait for the current image to load before preloading future
// images, so we don't slow down loading the current image by preloading too early.
if(illust_data.mangaPages.length >= 1)
results.push(new _img_preloader(illust_data.mangaPages[0].urls.original));
return results;
}
preload_thumbs(illust_info)
{
// We're only interested in preloading thumbs for manga pages.
if(illust_info.pageCount < 2)
return;
// Preload thumbs directly rather than queueing, since they load quickly and
// this reduces flicker in the manga thumbnail bar.
var thumbs = [];
for(var page of illust_info.mangaPages)
thumbs.push(page.urls.small);
helpers.preload_images(thumbs);
}
};
// This should be inside whats_new, but Firefox is in the dark ages and doesn't support class fields.
let _update_history = [
{
version: 68,
text:
"You can now go to either the first manga page or the page list from search results. " +
"Click the image to go to the first page, or the page count to go to the page list." +
"" +
"Our button is now in the bottom-left when we're disabled, since Pixiv now puts a menu " +
"button in the top-left and we were covering it up."
},
{
version: 65,
text:
"Bookmark viewing now remembers which page you were on if the page is reloaded." +
"
"+
"Zooming is now in smaller increments, to make it easier to zoom to the level you want."
},
{
version: 57,
text:
"Search for similar artists. Click the recommendations item at the top of the artist page, " +
"or in the top-left when viewing an image." +
"
"+
"You can also now view suggested artists."
},
{
version: 56,
text:
"Tag translations are now supported. This can be turned off in preferences. " +
"
" +
"Added quick tag search editing. After searching for a tag, click the edit button " +
"to quickly add and remove tags."
},
{
version: 55,
text:
"The \"original\" view is now available in Rankings." +
"
" +
"Hiding the mouse cursor can now be disabled in preferences.",
},
{
version: 49,
text:
"Add \"Hover to show UI\" preference, which is useful for low-res monitors."
},
{
version: 47,
text:
"You can now view the users you're following with \"Followed Users\". This shows each " +
"user's most recent post."
},
];
class whats_new
{
// Return the newest revision that exists in history. This is always the first
// history entry.
static latest_history_revision()
{
return _update_history[0].version;
}
constructor(container)
{
this.container = container;
this.refresh();
this.container.querySelector(".close-button").addEventListener("click", (e) => { this.hide(); });
// Close if the container is clicked, but not if something inside the container is clicked.
this.container.addEventListener("click", (e) => {
if(e.target != this.container)
return;
this.hide();
});
// Hide on any state change.
window.addEventListener("popstate", (e) => {
this.hide();
});
this.show();
}
refresh()
{
let items_box = this.container.querySelector(".items");
// Not really needed, since our contents never change
helpers.remove_elements(items_box);
let item_template = document.body.querySelector(".template-version-history-item");
for(let update of _update_history)
{
let entry = helpers.create_from_template(item_template);
entry.querySelector(".rev").innerText = "r" + update.version;
entry.querySelector(".text").innerHTML = update.text;
items_box.appendChild(entry);
}
}
show()
{
this.container.hidden = false;
}
hide()
{
this.container.hidden = true;
}
};
var debug_show_ui = false;
// This runs first and sets everything else up.
class early_controller
{
constructor()
{
// Early initialization. This happens before anything on the page is loaded, since
// this script runs at document-start.
//
// If this is an iframe, don't do anything. This may be a helper iframe loaded by
// load_data_in_iframe, in which case the main page will do the work.
if(window.top != window.self)
return;
// Don't activate for things like sketch.pixiv.net.
if(document.location.hostname != "www.pixiv.net")
return;
console.log("ppixiv setup");
// catch_bind isn't available if we're not active, so we use bind here.
this.dom_content_loaded = this.dom_content_loaded.bind(this);
if(document.readyState == "loading")
window.addEventListener("DOMContentLoaded", this.dom_content_loaded, true);
else
setTimeout(this.dom_content_loaded, 0);
if(!page_manager.singleton().active)
return;
// Do early setup. This happens early in page loading, without waiting for DOMContentLoaded.
// Unfortunately TamperMonkey doesn't correctly call us at the very start of the page in
// Chrome, so this doesn't happen until some site scripts have had a chance to run.
// Pixiv scripts run on DOMContentLoaded and load, whichever it sees first. Add capturing
// listeners on both of these and block propagation, so those won't be run. This keeps most
// of the site scripts from running underneath us. Make sure this is registered after our
// own DOMContentLoaded listener above, or it'll block ours too.
//
// This doesn't always work in Chrome. TamperMonkey often runs user scripts very late,
// even after DOMContentLoaded has already been sent, even in run-at: document-start.
var stop_event = function(e) {
e.preventDefault();
e.stopImmediatePropagation();
};
if(document.readyState == "loading")
window.addEventListener("DOMContentLoaded", stop_event, true);
window.addEventListener("load", stop_event, true);
// Install polyfills. Make sure we only do this if we're active, so we don't
// inject polyfills into Pixiv when we're not active.
install_polyfills();
// Newer Pixiv pages run a bunch of stuff from deferred scripts, which install a bunch of
// nastiness (like searching for installed polyfills--which we install--and adding wrappers
// around them). Break this by defining a webpackJsonp property that can't be set. It
// won't stop the page from running everything, but it keeps it from getting far enough
// for the weirder scripts to run.
//
// Also, some Pixiv pages set an onerror to report errors. Disable it if it's there,
// so it doesn't send errors caused by this script. Remove _send and _time, which
// also send logs. It might have already been set (TamperMonkey in Chrome doesn't
// implement run-at: document-start correctly), so clear it if it's there.
for(var key of ["onerror", "onunhandledrejection", "_send", "_time", "webpackJsonp"])
{
unsafeWindow[key] = null;
// Use an empty setter instead of writable: false, so errors aren't triggered all the time.
Object.defineProperty(unsafeWindow, key, {
get: exportFunction(function() { return null; }, unsafeWindow),
set: exportFunction(function(value) { }, unsafeWindow),
});
}
// Try to prevent site scripts from running, since we don't need any of it.
if(navigator.userAgent.indexOf("Firefox") != -1)
helpers.block_all_scripts();
this.temporarily_hide_document();
}
dom_content_loaded(e)
{
try {
this.setup();
} catch(e) {
// GM error logs don't make it to the console for some reason.
console.log(e);
}
}
temporarily_hide_document()
{
if(document.documentElement != null)
{
document.documentElement.hidden = true;
return;
}
// At this point, none of the document has loaded, and document.body and
// document.documentElement don't exist yet, so we can't hide it. However,
// we want to hide the document as soon as it's added, so we don't flash
// the original page before we have a chance to replace it. Use a mutationObserver
// to detect the document being created.
var observer = new MutationObserver(function(mutation_list) {
if(document.documentElement == null)
return;
observer.disconnect();
document.documentElement.hidden = true;
});
observer.observe(document, { attributes: false, childList: true, subtree: true });
};
// This is called on DOMContentLoaded (whether we're active or not).
setup()
{
// If we're not active, stop without doing anything and leave the page alone.
if(!page_manager.singleton().active)
{
// If we're disabled and can be enabled on this page, add our button.
this.setup_disabled_ui();
if(page_manager.singleton().available())
{
// Remember that we're disabled in this tab. This way, clicking the "return
// to Pixiv" button will remember that we're disabled. We do this on page load
// rather than when the button is clicked so this works when middle-clicking
// the button to open a regular Pixiv page in a tab.
//
// Only do this if we're available and disabled, which means the user disabled us.
// If we wouldn't be available on this page at all, don't store it.
page_manager.singleton().store_ppixiv_disabled(true);
}
return;
}
// Create the main controller.
main_controller.create_singleton();
}
// When we're disabled, but available on the current page, add the button to enable us.
setup_disabled_ui()
{
// On most pages, we show our button in the top corner to enable us on that page. Clicking
// it on a search page will switch to us on the same search.
var disabled_ui = helpers.create_node(resources['disabled.html']);
helpers.add_style('.ppixiv-disabled-ui > a { background-image: url("' + binary_data['activate-icon.png'] + '"); };');
// If we're on a page that we don't support, like the top page, rewrite the link to switch to
// a page we do support.
if(!page_manager.singleton().available())
disabled_ui.querySelector("a").href = "/ranking.php?mode=daily#ppixiv";
document.body.appendChild(disabled_ui);
};
}
// This handles high-level navigation and controlling the different views.
class main_controller
{
// We explicitly create this singleton rather than doing it on the first call to
// singleton(), so it's explicit when it's created.
static create_singleton()
{
if(main_controller._singleton != null)
throw "main_controller is already created";
new main_controller();
}
static get singleton()
{
if(main_controller._singleton == null)
throw "main_controller isn't created";
return main_controller._singleton;
}
constructor()
{
main_controller._singleton = this;
this.onkeydown = this.onkeydown.catch_bind(this);
this.redirect_event_to_view = this.redirect_event_to_view.catch_bind(this);
this.window_onclick_capture = this.window_onclick_capture.catch_bind(this);
this.window_onpopstate = this.window_onpopstate.catch_bind(this);
// Create the page manager.
page_manager.singleton();
this.setup();
};
async setup()
{
// This format is used on at least /new_illust.php.
let global_data = document.querySelector("#meta-global-data");
if(global_data != null)
global_data = JSON.parse(global_data.getAttribute("content"));
if(global_data && global_data.userData == null)
global_data = null;
// Try to init using globalInitData if possible. data.userData is null if the user is logged out.
var data = helpers.get_global_init_data(document);
if(data && data.userData == null)
data = null;
// This is the global "pixiv" object, which is used on older pages.
var pixiv = helpers.get_pixiv_data(document);
if(pixiv && (pixiv.user == null || pixiv.user.id == null))
pixiv = null;
// Pixiv scripts that use meta-global-data remove the element from the page after
// it's parsed for some reason. Since browsers are too broken to allow user scripts
// to reliably run before site scripts, it's hard for us to guarantee that we can
// get this data before it's removed.
//
// If we didn't get any init data, reload the page in an iframe and look for meta-global-data
// again. This request doesn't allow scripts to run. At least in Chrome, this comes out of
// cache, so it doesn't actually cause us to load the page twice.
if(global_data == null && data == null && pixiv == null)
{
console.log("Reloading page to get init data");
let url = new URL(document.location);
let result = await helpers.load_data_in_iframe(url.toString());
global_data = result.querySelector("#meta-global-data");
if(global_data != null)
global_data = JSON.parse(global_data.getAttribute("content"));
console.log("Finished loading init data");
}
// If we don't have either of these (or we're logged out), stop and let the regular page display.
// It may be a page we don't support.
if(global_data == null && data == null && pixiv == null)
{
console.log("Couldn't find context data. Are we logged in?");
document.documentElement.hidden = false;
return;
}
console.log("Starting");
// We know that we have enough info to continue, so we can do this now.
//
// Try to prevent the underlying page from making requests. It would be better to do this
// earlier, in early_controller's constructor, but we don't know for sure whether we'll be
// able to continue at that point, and we don't want to do this if we aren't. This used to
// matter more, but since browsers are bad and don't reliably allow user scripts to run early
// anymore, this wouldn't prevent all early network requests anyway.
//
// This needs to be done before calling anything else, or our internal network requests
// won't work.
helpers.block_network_requests();
if(global_data != null)
{
this.init_global_data(global_data.token, global_data.userData.id, global_data.userData.premium,
global_data.mute, global_data.userData.adult);
let preload = document.querySelector("#meta-preload-data");
if(preload != null)
{
preload = JSON.parse(preload.getAttribute("content"));
for(var preload_user_id in preload.user)
image_data.singleton().add_user_data(preload.user[preload_user_id]);
for(var preload_illust_id in preload.illust)
image_data.singleton().add_illust_data(preload.illust[preload_illust_id]);
}
}
else if(data != null)
{
this.init_global_data(data.token, data.userData.id, data.premium && data.premium.popularSearch, data.mute, data.userData.xRestrict);
// If data is available, this is a newer page with globalInitData.
// This can have one or more user and/or illust data, which we'll preload
// so we don't need to fetch it later.
//
// Preload users before illusts. Otherwise, adding the illust will cause image_data
// to fetch user info to fill it in.
for(var preload_user_id in data.preload.user)
image_data.singleton().add_user_data(data.preload.user[preload_user_id]);
for(var preload_illust_id in data.preload.illust)
image_data.singleton().add_illust_data(data.preload.illust[preload_illust_id]);
}
else
{
this.init_global_data(pixiv.context.token, pixiv.user.id, pixiv.user.premium, pixiv.user.mutes, pixiv.user.explicit);
}
window.addEventListener("click", this.window_onclick_capture);
window.addEventListener("popstate", this.window_onpopstate);
window.addEventListener("keyup", this.redirect_event_to_view, true);
window.addEventListener("keydown", this.redirect_event_to_view, true);
window.addEventListener("keypress", this.redirect_event_to_view, true);
window.addEventListener("keydown", this.onkeydown);
this.current_view_name = null;
this.current_history_index = helpers.current_history_state_index();
// Don't restore the scroll position.
//
// If we browser back to a search page and we were scrolled ten pages down, scroll
// restoration will try to scroll down to it incrementally, causing us to load all
// data in the search from the top all the way down to where we were. This can cause
// us to spam the server with dozens of requests. This happens on F5 refresh, which
// isn't useful (if you're refreshing a search page, you want to see new results anyway),
// and recommendations pages are different every time anyway.
//
// This won't affect browser back from an image to the enclosing search.
history.scrollRestoration = "manual";
// Remove everything from the page and move it into a dummy document.
var html = document.createElement("document");
helpers.move_children(document.head, html);
helpers.move_children(document.body, html);
// Copy the location to the document copy, so the data source can tell where
// it came from.
html.location = document.location;
// Now that we've cleared the document, we can unhide it.
document.documentElement.hidden = false;
// Add binary resources as CSS styles.
helpers.add_style('body .noise-background { background-image: url("' + binary_data['noise.png'] + '"); };');
helpers.add_style('body.light .noise-background { background-image: url("' + binary_data['noise-light.png'] + '"); };');
helpers.add_style('.ugoira-icon { background-image: url("' + binary_data['play-button.svg'] + '"); };');
helpers.add_style('.page-icon { background-image: url("' + binary_data['page-icon.png'] + '"); };');
helpers.add_style('.page-count-box:hover .page-icon { background-image: url("' + binary_data['page-icon-hover.png'] + '"); };');
// Add the main CSS style.
helpers.add_style(resources['main.css']);
// Create the page from our HTML resource.
document.body.insertAdjacentHTML("beforeend", resources['main.html']);
// Create the shared title and page icon.
document.head.appendChild(document.createElement("title"));
var document_icon = document.head.appendChild(document.createElement("link"));
document_icon.setAttribute("rel", "icon");
helpers.add_clicks_to_search_history(document.body);
this.container = document.body;
// Create the popup menu handler.
this.context_menu = new main_context_menu(document.body);
// Create the main progress bar.
this.progress_bar = new progress_bar(this.container.querySelector(".loading-progress-bar"));
// Create the thumbnail view handler.
this.thumbnail_view = new view_search(this.container.querySelector(".view-search-container"));
// Create the manga page viewer.
this.manga_view = new view_manga(this.container.querySelector(".view-manga-container"));
// Create the main UI.
this.ui = new view_illust(this.container.querySelector(".view-illust-container"));
this.views = {
search: this.thumbnail_view,
illust: this.ui,
manga: this.manga_view,
};
// Create the data source for this page.
this.set_current_data_source(html, "initialization");
};
window_onpopstate(e)
{
// A special case for the bookmarks data source. It changes its page in the URL to mark
// how far the user has scrolled. We don't want this to trigger a data source change.
if(this.temporarily_ignore_onpopstate)
{
console.log("Not navigating for internal page change");
return;
}
// Set the current data source and state.
this.set_current_data_source(null, e.navigationCause || "history");
}
async refresh_current_data_source()
{
if(this.data_source == null)
return;
// Create a new data source for the same URL, replacing the previous one.
// This returns the data source, but just call set_current_data_source so
// we load the new one.
console.log("Refreshing data source for", document.location.toString());
await page_manager.singleton().create_data_source_for_url(document.location, null, true);
await this.set_current_data_source(null, "refresh");
}
// Create a data source for the current URL and activate it.
//
// This is called on startup, and in onpopstate where we might be changing data sources.
//
// If this is on startup, html is the HTML elements on the page to pass to the data source
// to preload the first page. On navigation, html is null. If we navigate to a page that
// can load the first page from the HTML page, we won't load the HTML and we'll just allow
// the first page to load like any other page.
async set_current_data_source(html, cause)
{
// Get the current data source. If we've already created it, this will just return
// the same object and not create a new one.
var data_source = await page_manager.singleton().create_data_source_for_url(document.location, html);
// If the data source is changing, set it.
if(this.data_source != data_source)
{
// Shut down the old data source.
if(this.data_source != null)
this.data_source.shutdown();
// If we were showing a message for the old data source, it might be persistent,
// so clear it.
message_widget.singleton.hide();
this.data_source = data_source;
this.show_data_source_specific_elements();
this.ui.set_data_source(data_source);
this.thumbnail_view.set_data_source(data_source);
this.context_menu.set_data_source(data_source);
if(this.data_source != null)
this.data_source.startup();
}
if(data_source == null)
return;
// Figure out which view to display.
var new_view_name;
var args = helpers.get_args(document.location);
if(!args.hash.has("view"))
new_view_name = this.data_source.default_view;
else
new_view_name = args.hash.get("view");
var args = helpers.get_args(document.location);
var illust_id = data_source.get_current_illust_id();
var manga_page = args.hash.has("page")? parseInt(args.hash.get("page"))-1:null;
// If we're on search, we don't care what image is current. Clear illust_id so we
// tell context_menu that we're not viewing anything, so it disables bookmarking.
if(new_view_name == "search")
illust_id = null;
// if illust_id is set, need the image data to know whether to show manga pages
// or the illust
console.log("Loading data source. View:", new_view_name, "Cause:", cause, "URL:", document.location.href);
// Get the manga page in this illust to show, if any.
console.log(" Show image", illust_id, "page", manga_page);
// Mark the current view. Other code can watch for this to tell which view is
// active.
document.body.dataset.currentView = new_view_name;
// Set the image before activating the view. If we do this after activating it,
// it'll start loading any previous image it was pointed at. Don't do this in
// search mode, or we'll start loading the default image.
if(new_view_name == "illust")
this.ui.show_image(illust_id, manga_page);
else if(new_view_name == "manga")
this.manga_view.shown_illust_id = illust_id;
var new_view = this.views[new_view_name];
var old_view = this.views[this.current_view_name];
var old_illust_id = old_view? old_view.displayed_illust_id:null;
var old_illust_page = old_view? old_view.displayed_illust_page:null;
// main_context_menu uses this to see which view is active.
document.body.dataset.currentView = new_view_name;
this.context_menu.illust_id = illust_id;
// If we're changing between views, update the active view.
var view_changing = new_view != old_view;
if(view_changing)
{
this.current_view_name = new_view_name;
// Make sure we deactivate the old view before activating the new one.
if(old_view != null)
old_view.active = false;
if(new_view != null)
new_view.active = true;
// Dismiss any message when toggling between views.
message_widget.singleton.hide();
// If we're enabling the thumbnail, pulse the image that was just being viewed (or
// loading to be viewed), to make it easier to find your place.
if(new_view_name == "search" && old_illust_id != null)
this.thumbnail_view.pulse_thumbnail(old_illust_id);
}
// Are we navigating forwards or back?
var new_history_index = helpers.current_history_state_index();
var navigating_forwards = cause == "history" && new_history_index > this.current_history_index;
this.current_history_index = new_history_index;
// Handle scrolling for the new state.
//
// We could do this better with history.state (storing each state's scroll position would
// allow it to restore across browser sessions, and if the same data source is multiple
// places in history). Unfortunately there's no way to update history.state without
// calling history.replaceState, which is slow and causes jitter. history.state being
// read-only is a design bug in the history API.
if(cause == "navigation")
{
// If this is an initial navigation, eg. from a user clicking a link to a search, always
// scroll to the top. If this data source exists previously in history, we don't want to
// restore the scroll position from back then.
console.log("Scroll to top for new search");
new_view.scroll_to_top();
}
else if(navigating_forwards)
{
// On browser history forwards, try to restore the scroll position.
console.log("Restore scroll position for forwards navigation");
new_view.restore_scroll_position();
}
else if(view_changing && old_illust_id != null)
{
// If we're navigating backwards or toggling, and we're switching from the image UI to thumbnails,
// try to scroll the thumbnail view to the image that was displayed. Otherwise, tell
// the thumbnail view to restore any scroll position saved in the data source.
console.log("Scroll to", old_illust_id, old_illust_page);
new_view.scroll_to_illust_id(old_illust_id, old_illust_page);
}
else
{
new_view.restore_scroll_position();
}
}
show_data_source_specific_elements()
{
// Show UI elements with this data source in their data-datasource attribute.
var data_source_name = this.data_source.name;
for(var node of this.container.querySelectorAll(".data-source-specific[data-datasource]"))
{
var data_sources = node.dataset.datasource.split(" ");
var show_element = data_sources.indexOf(data_source_name) != -1;
node.hidden = !show_element;
}
}
// Show an illustration by ID.
//
// This actually just sets the history URL. We'll do the rest of the work in popstate.
show_illust(illust_id, options)
{
if(options == null)
options = {};
var manga_page = options.manga_page != null? options.manga_page:null;
var add_to_history = options.add_to_history || false;
var view = options.view || "illust";
// Sanity check:
if(illust_id == null)
{
console.error("Invalid illust_id", illust_id);
return;
}
// Set the wanted illust_id in the URL, and disable the thumb view so we show
// the image. Do this in a single URL update, so we don't add multiple history
// entries.
var args = helpers.get_args(document.location);
this._set_active_view_in_url(args.hash, view);
this.data_source.set_current_illust_id(illust_id, args);
// Remove any leftover page from the current illust. We'll load the default.
if(manga_page == null)
args.hash.delete("page");
else
args.hash.set("page", manga_page + 1);
helpers.set_args(args, add_to_history, "navigation");
}
// Return the displayed view instance.
get displayed_view()
{
for(var view_name in this.views)
{
var view = this.views[view_name];
if(view.active)
return view;
}
return null;
}
_set_active_view_in_url(hash_args, view)
{
hash_args.set("view", view);
}
set_displayed_view_by_name(view, add_to_history, cause)
{
// Update the URL to mark whether thumbs are displayed.
var args = helpers.get_args(document.location);
this._set_active_view_in_url(args.hash, view);
helpers.set_args(args, add_to_history, cause);
}
// Navigate out.
//
// This navigates from the illust page to the manga page (for multi-page posts) or search, and
// from the manga page to search.
//
// This is similar to browser back, but allows moving up to the search even for new tabs. It
// would be better for this to integrate with browser history (just browser back if browser back
// is where we're going), but for some reason you can't view history state entries even if they're
// on the same page, so there's no way to tell where History.back() would take us.
_get_navigate_out_target()
{
var new_page = null;
var view = this.displayed_view;
// This gets called by the popup menu when it's created before we have any view.
if(view == null)
return [null, null];
if(view == this.views.manga)
{
return ["search", "search"];
}
else if(view == this.views.illust)
{
var page_count = view.current_illust_data != null? view.current_illust_data.pageCount:1;
if(page_count > 1)
return ["manga", "page list"];
else
return ["search", "search"];
}
else
return [null, null];
}
get navigate_out_label()
{
var target = this._get_navigate_out_target();
return target[1];
}
navigate_out()
{
var target = this._get_navigate_out_target();
var new_page = target[0];
if(new_page != null)
this.set_displayed_view_by_name(new_page, true /*add_to_history*/, "out");
}
// This captures clicks at the window level, allowing us to override them.
//
// When the user left clicks on a link that also goes into one of our views,
// rather than loading a new page, we just set up a new data source, so we
// don't have to do a full navigation.
//
// This only affects left clicks (middle clicks into a new tab still behave
// normally).
window_onclick_capture(e)
{
// Only intercept regular left clicks.
if(e.button != 0 || e.metaKey || e.ctrlKey || e.altKey)
return;
if(!(e.target instanceof Element))
return;
// Look up from the target for a link.
var a = e.target.closest("A");
if(a == null)
return;
// If this isn't a #ppixiv URL, let it run normally.
var url = new URL(a.href, document.href);
var is_ppixiv_url = helpers.parse_hash(url) != null;
if(!is_ppixiv_url)
return;
// Stop all handling for this link.
e.preventDefault();
e.stopImmediatePropagation();
// Search links to images always go to /artworks/#, but if they're clicked in-page we
// want to stay on the same search and just show the image, so handle them directly.
var url = new unsafeWindow.URL(url);
url = helpers.get_url_without_language(url);
if(url.pathname.startsWith("/artworks/"))
{
let parts = url.pathname.split("/");
let illust_id = parts[2];
var args = helpers.get_args(a.href);
var page = args.hash.has("page")? parseInt(args.hash.get("page"))-1: null;
var view = args.hash.has("view")? args.hash.get("view"):"illust";
this.show_illust(illust_id, {
view: view,
manga_page: page,
add_to_history: true
});
return;
}
// Navigate to the URL in-page.
helpers.set_page_url(url, true /* add to history */, "navigation");
}
init_global_data(csrf_token, user_id, premium, mutes, content_mode)
{
var muted_tags = [];
var muted_user_ids = [];
for(var mute of mutes)
{
if(mute.type == 0)
muted_tags.push(mute.value);
else if(mute.type == 1)
muted_user_ids.push(mute.value);
}
muting.singleton.set_muted_tags(muted_tags);
muting.singleton.set_muted_user_ids(muted_user_ids);
window.global_data = {
// Store the token for XHR requests.
csrf_token: csrf_token,
user_id: user_id,
include_r18: content_mode >= 1,
include_r18g: content_mode >= 2,
};
// Set the .premium class on body if this is a premium account, to display features
// that only work with premium.
helpers.set_class(document.body, "premium", premium);
// These are used to hide buttons that the user has disabled.
helpers.set_class(document.body, "hide-r18", !window.global_data.include_r18);
helpers.set_class(document.body, "hide-r18g", !window.global_data.include_r18g);
};
// Redirect keyboard events that didn't go into the active view.
redirect_event_to_view(e)
{
var view = this.displayed_view;
if(view == null)
return;
// If a popup is open, leave inputs alone.
if(document.body.dataset.popupOpen)
return;
// If the keyboard input didn't go to an element inside the view, redirect
// it to the view's container.
var target = e.target;
// If the event is going to an element inside the view already, just let it continue.
if(helpers.is_above(view.container, e.target))
return;
// Clone the event and redispatch it to the view's container.
var e2 = new e.constructor(e.type, e);
if(!view.container.dispatchEvent(e2))
{
e.preventDefault();
e.stopImmediatePropagation();
return;
}
}
onkeydown(e)
{
// Ignore keypresses if we haven't set up the view yet.
var view = this.displayed_view;
if(view == null)
return;
// If a popup is open, leave inputs alone and don't process hotkeys.
if(document.body.dataset.popupOpen)
return;
if(e.keyCode == 27) // escape
{
e.preventDefault();
e.stopPropagation();
this.navigate_out();
return;
}
// Let the view handle the input.
view.handle_onkeydown(e);
}
};
new early_controller();
})();