\`});
entry.dataset.tag = tag_search;
let translated_tag = translated_tags[tag_search];
if(translated_tag)
entry.dataset.translated_tag = translated_tag;
let tag_container = entry.querySelector(".search");
for(let tag of helpers.split_search_tags(tag_search))
{
if(tag == "")
continue;
// Force "or" lowercase.
if(tag.toLowerCase() == "or")
tag = "or";
let span = document.createElement("span");
span.dataset.tag = tag;
span.classList.add("word");
if(tag == "or")
span.classList.add("or");
else
span.classList.add("tag");
// Split off - prefixes to look up the translation, then add it back.
let prefix_and_tag = helpers.split_tag_prefixes(tag);
let translated_tag = translated_tags[prefix_and_tag[1]];
if(translated_tag)
translated_tag = prefix_and_tag[0] + translated_tag;
span.innerText = translated_tag || tag;
if(translated_tag)
span.dataset.translated_tag = translated_tag;
tag_container.appendChild(span);
}
var url = page_manager.singleton().get_url_for_tag_search(tag_search, ppixiv.location);
entry.href = url;
return entry;
}
set_selection(idx)
{
// Temporarily set this.navigating to true. This lets run_autocomplete know that
// it shouldn't run an autocomplete request for this value change.
this.navigating = true;
try {
// If there's an autocomplete request in the air, cancel it.
if(this.abort_autocomplete != null)
this.abort_autocomplete.abort();
// Clear any old selection.
var all_entries = this.container.querySelectorAll(".input-dropdown-list .entry");
if(this.selected_idx != null)
all_entries[this.selected_idx].classList.remove("selected");
// Set the new selection.
this.selected_idx = idx;
if(this.selected_idx != null)
{
var new_entry = all_entries[this.selected_idx];
new_entry.classList.add("selected");
this.input_element.value = new_entry.dataset.tag;
}
} finally {
this.navigating = false;
}
}
// Select the next or previous entry in the dropdown.
move(down)
{
var all_entries = this.container.querySelectorAll(".input-dropdown-list .entry");
// Stop if there's nothing in the list.
var total_entries = all_entries.length;
if(total_entries == 0)
return;
var idx = this.selected_idx;
if(idx == null)
idx = down? 0:(total_entries-1);
else
idx += down? +1:-1;
idx %= total_entries;
this.set_selection(idx);
}
// Populate the tag dropdown.
//
// This is async, since IndexedDB is async. (It shouldn't be. It's an overcorrection.
// Network APIs should be async, but local I/O should not be forced async.) If another
// call to populate_dropdown() is made before this completes or cancel_populate_dropdown
// cancels it, return false. If it completes, return true.
async populate_dropdown()
{
// If another populate_dropdown is already running, cancel it and restart.
this.cancel_populate_dropdown();
// Set populate_dropdown_abort to an AbortController for this call.
let abort_controller = this.populate_dropdown_abort = new AbortController();
let abort_signal = abort_controller.signal;
var tag_searches = settings.get("recent-tag-searches") || [];
// Separate tags in each search, so we can look up translations.
//
var all_tags = {};
for(let tag_search of tag_searches)
{
for(let tag of helpers.split_search_tags(tag_search))
{
tag = helpers.split_tag_prefixes(tag)[1];
all_tags[tag] = true;
}
}
all_tags = Object.keys(all_tags);
let translated_tags = await tag_translations.get().get_translations(all_tags, "en");
// Check if we were aborted while we were loading tags.
if(abort_signal && abort_signal.aborted)
{
console.log("populate_dropdown_inner aborted");
return false;
}
var list = this.container.querySelector(".input-dropdown-list");
helpers.remove_elements(list);
this.selected_idx = null;
var autocompleted_tags = this.current_autocomplete_results;
for(var tag of autocompleted_tags)
{
var entry = this.create_entry(tag.tag_name, translated_tags);
entry.classList.add("autocomplete");
list.appendChild(entry);
}
for(var tag of tag_searches)
{
var entry = this.create_entry(tag, translated_tags);
entry.classList.add("history");
list.appendChild(entry);
}
return true;
}
cancel_populate_dropdown()
{
if(this.populate_dropdown_abort == null)
return;
this.populate_dropdown_abort.abort();
}
}
ppixiv.tag_search_edit_widget = class extends ppixiv.widget
{
constructor({input_element, ...options})
{
super({...options, template: \`
\`});
this.dropdown_onclick = this.dropdown_onclick.bind(this);
this.populate_dropdown = this.populate_dropdown.bind(this);
this.input_element = input_element.querySelector("input");
// Refresh the dropdown when the tag search history changes.
window.addEventListener("recent-tag-searches-changed", this.populate_dropdown);
this.container.addEventListener("click", this.dropdown_onclick);
// Refresh tags if the user edits the search directly.
this.input_element.addEventListener("input", (e) => { this.refresh_highlighted_tags(); });
// input-dropdown is resizable. Save the size when the user drags it.
this.input_dropdown = this.container.querySelector(".input-dropdown");
let observer = new MutationObserver((mutations) => {
// resize sets the width. Use this instead of offsetWidth, since offsetWidth sometimes reads
// as 0 here.
settings.set("search-edit-dropdown-width", this.input_dropdown.style.width);
});
observer.observe(this.input_dropdown, { attributes: true });
// Restore input-dropdown's width. Force a minimum width, in case this setting is saved incorrectly.
this.input_dropdown.style.width = settings.get("search-edit-dropdown-width", "400px");
this.shown = false;
this.container.hidden = true;
}
dropdown_onclick(e)
{
e.preventDefault();
e.stopImmediatePropagation();
// Clicking tags toggles the tag in the search box.
let tag = e.target.closest(".tag");
if(tag == null)
return;
this.toggle_tag(tag.dataset.tag);
// Control-clicking the tag probably caused its enclosing search link to be focused, which will
// cause it to activate when enter is pressed. Switch focus to the input box, so pressing enter
// will submit the search.
this.input_element.focus();
}
async show()
{
if(this.shown)
return;
this.shown = true;
// Fill in the dropdown before displaying it. If hide() is called before this
// finishes this will return false, so stop.
if(!await this.populate_dropdown())
return;
this.container.hidden = false;
}
hide()
{
if(!this.shown)
return;
this.shown = false;
// If populate_dropdown is still running, cancel it.
this.cancel_populate_dropdown();
this.container.hidden = true;
// Make sure the input isn't focused.
this.input_element.blur();
}
// tag_search is a search, like "tag -tag2". translated_tags is a dictionary of known translations.
create_entry(tag_search, translated_tags)
{
let entry = this.create_template({name: "dropdown-entry", html: \`
\`});
entry.dataset.tag = tag_search;
let translated_tag = translated_tags[tag_search];
if(translated_tag)
entry.dataset.translated_tag = translated_tag;
let tag_container = entry.querySelector(".search");
for(let tag of helpers.split_search_tags(tag_search))
{
if(tag == "")
continue;
let span = document.createElement("span");
span.dataset.tag = tag;
span.classList.add("word");
if(tag != "or")
span.classList.add("tag");
// Split off - prefixes to look up the translation, then add it back.
let prefix_and_tag = helpers.split_tag_prefixes(tag);
let translated_tag = translated_tags[prefix_and_tag[1]];
if(translated_tag)
translated_tag = prefix_and_tag[0] + translated_tag;
span.innerText = translated_tag || tag;
if(translated_tag)
span.dataset.translated_tag = translated_tag;
tag_container.appendChild(span);
}
var url = page_manager.singleton().get_url_for_tag_search(tag_search, ppixiv.location);
entry.querySelector("A.search").href = url;
return entry;
}
// Populate the tag dropdown.
//
// This is async, since IndexedDB is async. (It shouldn't be. It's an overcorrection.
// Network APIs should be async, but local I/O should not be forced async.) If another
// call to populate_dropdown() is made before this completes or cancel_populate_dropdown
// cancels it, return false. If it completes, return true.
async populate_dropdown()
{
// If another populate_dropdown is already running, cancel it and restart.
this.cancel_populate_dropdown();
// Set populate_dropdown_abort to an AbortController for this call.
let abort_controller = this.populate_dropdown_abort = new AbortController();
let abort_signal = abort_controller.signal;
var tag_searches = settings.get("recent-tag-searches") || [];
// Individually show all tags in search history.
var all_tags = {};
for(let tag_search of tag_searches)
{
for(let tag of helpers.split_search_tags(tag_search))
{
tag = helpers.split_tag_prefixes(tag)[1];
// Ignore "or".
if(tag == "" || tag == "or")
continue;
all_tags[tag] = true;
}
}
all_tags = Object.keys(all_tags);
let translated_tags = await tag_translations.get().get_translations(all_tags, "en");
// Sort tags by their translation.
all_tags.sort((lhs, rhs) => {
if(translated_tags[lhs]) lhs = translated_tags[lhs];
if(translated_tags[rhs]) rhs = translated_tags[rhs];
return lhs.localeCompare(rhs);
});
// Check if we were aborted while we were loading tags.
if(abort_signal && abort_signal.aborted)
{
console.log("populate_dropdown_inner aborted");
return false;
}
var list = this.container.querySelector(".input-dropdown-list");
helpers.remove_elements(list);
for(var tag of all_tags)
{
var entry = this.create_entry(tag, translated_tags);
list.appendChild(entry);
}
this.refresh_highlighted_tags();
return true;
}
cancel_populate_dropdown()
{
if(this.populate_dropdown_abort == null)
return;
this.populate_dropdown_abort.abort();
}
refresh_highlighted_tags()
{
let tags = helpers.split_search_tags(this.input_element.value);
var list = this.container.querySelector(".input-dropdown-list");
for(let tag_entry of list.querySelectorAll("[data-tag]"))
{
let tag = tag_entry.dataset.tag;
let tag_selected = tags.indexOf(tag) != -1;
helpers.set_class(tag_entry, "highlight", tag_selected);
}
}
// Add or remove tag from the tag search. This doesn't affect -tag searches.
toggle_tag(tag)
{
console.log("Toggle tag:", tag);
let tags = helpers.split_search_tags(this.input_element.value);
let idx = tags.indexOf(tag);
if(idx != -1)
tags.splice(idx, 1);
else
tags.push(tag);
this.input_element.value = tags.join(" ");
this.refresh_highlighted_tags();
// Navigate to the edited search immediately. Don't add these to history, since it
// spams navigation history.
helpers.set_page_url(page_manager.singleton().get_url_for_tag_search(this.input_element.value, ppixiv.location), false);
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/tag_search_dropdown_widget.js
`;
ppixiv.resources["src/recently_seen_illusts.js"] = `"use strict";
ppixiv.recently_seen_illusts = class
{
// Return the singleton, creating it if needed.
static get()
{
if(recently_seen_illusts._singleton == null)
recently_seen_illusts._singleton = new recently_seen_illusts();
return recently_seen_illusts._singleton;
};
constructor()
{
this.db = new key_storage("ppixiv-recent-illusts", { db_upgrade: this.db_upgrade });
settings.register_change_callback("no_recent_history", this.update_from_settings);
this.update_from_settings();
}
get enabled()
{
return !settings.get("no_recent_history");
}
update_from_settings = () =>
{
// If the user disables recent history, clear our storage.
if(!this.enabled)
{
console.log("Clearing history");
this.clear();
}
}
db_upgrade = (e) => {
// Create our object store with an index on last_seen.
let db = e.target.result;
let store = db.createObjectStore("ppixiv-recent-illusts");
store.createIndex("last_seen", "last_seen");
}
async add_illusts(media_ids)
{
// Clean up old illusts. We don't need to wait for this.
this.purge_old_illusts();
// Stop if we're not enabled.
if(!this.enabled)
return;
let time = Date.now();
let data = {};
let idx = 0;
for(let media_id of media_ids)
{
// Store thumbnail info with the image. Every data_source these days is able
// to fill in thumbnail data as part of the request, so we store the thumbnail
// info to be able to do the same in data_source.recent. We're called when
// a thumbnail is being displayed, so
let thumb_info = thumbnail_data.singleton().get_one_thumbnail_info(media_id);
if(thumb_info == null)
continue;
data[media_id] = {
// Nudge the time back slightly as we go, so illustrations earlier in the list will
// be treated as older. This causes them to sort earlier in the recent illustrations
// view. If we don't do this, they'll be displayed in an undefined order.
last_seen: time - idx,
thumb_info: thumb_info,
};
idx++;
}
// Batch write:
await this.db.multi_set(data);
}
async clear()
{
}
// Return media_ids for recently viewed illusts, most recent first.
async get_recent_media_ids()
{
return await this.db.db_op(async (db) => {
let store = this.db.get_store(db);
return await this.get_stored_illusts(store, "new");
});
}
// Return thumbnail data for the given media IDs if we have it.
async get_thumbnail_info(media_ids)
{
return await this.db.db_op(async (db) => {
let store = this.db.get_store(db);
// Load the thumbnail info in bulk.
let promises = {};
for(let media_id of media_ids)
promises[media_id] = key_storage.async_store_get(store, media_id);
await Promise.all(Object.values(promises));
let results = [];
for(let media_id of media_ids)
{
let entry = await promises[media_id];
if(entry && entry.thumb_info)
results.push(entry.thumb_info);
}
return results;
});
}
// Clean up IDs that haven't been seen in a while.
async purge_old_illusts()
{
await this.db.db_op(async (db) => {
let store = this.db.get_store(db);
let ids_to_delete = await this.get_stored_illusts(store, "old");
if(ids_to_delete.length == 0)
return;
await this.db.multi_delete(ids_to_delete);
});
}
// Get illusts in the database. If which is "new", return ones that we want to display
// to the user. If it's "old", return ones that should be deleted.
async get_stored_illusts(store, which="new")
{
// Read illustrations seen within the last hour, newest first.
let index = store.index("last_seen");
let starting_from = Date.now() - (60*60*1000);
let query = which == "new"? IDBKeyRange.lowerBound(starting_from):IDBKeyRange.upperBound(starting_from);
let cursor = index.openCursor(query, "prev");
let results = [];
for await (let entry of cursor)
results.push(entry.primaryKey);
return results;
}
// Clear history.
async clear()
{
return await this.db.clear();
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/recently_seen_illusts.js
`;
ppixiv.resources["src/tag_translations.js"] = `"use strict";
ppixiv.tag_translations = class
{
// Return the singleton, creating it if needed.
static get()
{
if(tag_translations._singleton == null)
tag_translations._singleton = new tag_translations();
return tag_translations._singleton;
};
constructor()
{
this.db = new key_storage("ppixiv-tag-translations");
}
// Store a list of tag translations.
//
// tag_list is a dictionary:
// {
// original_tag: {
// en: "english tag",
// }
// }
async add_translations_dict(tags)
{
let translations = [];
for(let tag of Object.keys(tags))
{
let tag_info = tags[tag];
let tag_translation = {};
for(let lang of Object.keys(tag_info))
{
if(tag_info[lang] == "")
continue;
tag_translation[lang] = tag_info[lang];
}
if(Object.keys(tag_translation).length > 0)
{
translations.push({
tag: tag,
translation: tag_translation,
});
}
}
this.add_translations(translations);
}
// Store a list of tag translations.
//
// tag_list is a list of
// {
// tag: "original tag",
// translation: {
// en: "english tag",
// },
// }
//
// This is the same format that Pixiv uses in newer APIs. Note that we currently only store
// English translations.
async add_translations(tag_list)
{
let data = {};
for(let tag of tag_list)
{
// If a tag has no keys and no romanization, skip it so we don't fill our database
// with useless entries.
if((tag.translation == null || Object.keys(tag.translation).length == 0) && tag.romaji == null)
continue;
// Remove empty translation values.
let translation = {};
for(let lang of Object.keys(tag.translation || {}))
{
let value = tag.translation[lang];
if(value != "")
translation[lang] = value;
}
// Store the tag data that we care about. We don't need to store post-specific info
// like "deletable".
let tag_info = {
tag: tag.tag,
translation: translation,
};
if(tag.romaji)
tag_info.romaji = tag.romaji;
data[tag.tag] = tag_info;
}
// Batch write:
await this.db.multi_set(data);
}
async get_tag_info(tags)
{
// If the user has disabled translations, don't return any.
if(settings.get("disable-translations"))
return {};
let result = {};
let translations = await this.db.multi_get(tags);
for(let i = 0; i < tags.length; ++i)
{
if(translations[i] == null)
continue;
result[tags[i]] = translations[i];
}
return result;
}
async get_translations(tags, language="en")
{
let info = await this.get_tag_info(tags);
let result = {};
for(let tag of tags)
{
if(info[tag] == null || info[tag].translation == null)
continue;
// Skip this tag if we don't have a translation for this language.
let translation = info[tag].translation[language];
if(translation == null)
continue;
result[tag] = translation;
}
return result;
}
// Given a tag search, return a translated search.
async translate_tag_list(tags, language)
{
// Pull out individual tags, removing -prefixes.
let split_tags = helpers.split_search_tags(tags);
let tag_list = [];
for(let tag of split_tags)
{
let prefix_and_tag = helpers.split_tag_prefixes(tag);
tag_list.push(prefix_and_tag[1]);
}
// Get translations.
let translated_tags = await this.get_translations(tag_list, language);
// Put the search back together.
let result = [];
for(let one_tag of split_tags)
{
let prefix_and_tag = helpers.split_tag_prefixes(one_tag);
let prefix = prefix_and_tag[0];
let tag = prefix_and_tag[1];
if(translated_tags[tag])
tag = translated_tags[tag];
result.push(prefix + tag);
}
return result;
}
// A shortcut to retrieve one translation. If no translation is available, returns the
// original tag.
async get_translation(tag, language="en")
{
let translated_tags = await tag_translations.get().get_translations([tag], "en");
if(translated_tags[tag])
return translated_tags[tag];
else
return tag;
}
// Set the innerText of an element to tag, translating it if possible.
//
// This is async to look up the tag translation, but it's safe to release this
// without awaiting.
async set_translated_tag(element, tag)
{
let original_tag = tag;
element.dataset.tag = original_tag;
tag = await this.get_translation(tag);
// Stop if another call was made here while we were async.
if(element.dataset.tag != original_tag)
return;
element.innerText = tag;
}
}
// This updates the pp_tag_translations IDB store to ppixiv-tag-translations.
//
// The older database code kept the database open all the time. That's normal in every
// database in the world, except for IDB where it'll wedge everything (even the Chrome
// inspector window) if you try to change object stores. Read it out and write it to a
// new database, so users upgrading don't have to restart their browser to get tag translations
// back.
//
// This doesn't delete the old database, since for some reason that fires versionchange, which
// might make other tabs misbehave since they're not expecting it. We can add some code to
// clean up the old database later on when we can assume everybody has done this migration.
ppixiv.update_translation_storage = class
{
static run()
{
let update = new this();
update.update();
}
constructor()
{
this.name = "pp_tag_translations";
}
async db_op(func)
{
let db = await this.open_database();
try {
return await func(db);
} finally {
db.close();
}
}
open_database()
{
return new Promise((resolve, reject) => {
let request = indexedDB.open("ppixiv");
request.onsuccess = e => { resolve(e.target.result); };
request.onerror = e => { resolve(null); };
});
}
async_store_get(store)
{
return new Promise((resolve, reject) => {
let request = store.getAll();
request.onsuccess = e => resolve(e.target.result);
request.onerror = reject;
});
}
async update()
{
// Firefox is missing indexedDB.databases, so Firefox users get to wait for
// tag translations to repopulate.
if(!indexedDB.databases)
return;
// If the ppixiv-tag-translations database exists, assume this migration has already been done.
// First see if the old database exists and the new one doesn't.
let found = false;
for(let db of await indexedDB.databases())
{
if(db.name == "ppixiv-tag-translations")
return;
if(db.name == "ppixiv")
found = true;
}
if(!found)
return;
console.log("Migrating translation database");
// Open the old db.
return await this.db_op(async (db) => {
if(db == null)
return;
let transaction = db.transaction(this.name, "readonly");
let store = transaction.objectStore(this.name);
let results = await this.async_store_get(store);
let translations = [];
for(let result of results)
{
try {
if(!result.tag || !result.translation)
continue;
let data = {
tag: result.tag,
translation: { },
};
if(result.romaji)
data.romaji = result.romaji;
let empty = true;
for(let lang in result.translation)
{
let translated = result.translation[lang];
if(!translated)
continue;
data.translation[lang] = translated;
empty = false;
}
if(empty)
continue;
translations.push(data);
} catch(e) {
// Tolerate errors, in case there's weird junk in this database.
console.log("Error updating tag:", result);
}
}
await tag_translations.get().add_translations(translations);
});
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/tag_translations.js
`;
ppixiv.resources["src/thumbnail_data.js"] = `"use strict";
// This handles batch fetching data for thumbnails.
//
// We can load a bunch of images at once with illust_list.php. This isn't enough to
// display the illustration, since it's missing a lot of data, but it's enough for
// displaying thumbnails (which is what the page normally uses it for).
ppixiv.thumbnail_data = class
{
constructor()
{
this.loaded_thumbnail_info = this.loaded_thumbnail_info.bind(this);
// Cached data:
this.thumbnail_data = { };
this.quick_user_data = { };
this.user_profile_urls = {};
// IDs that we're currently requesting:
this.loading_ids = {};
};
// Return the singleton, creating it if needed.
static singleton()
{
if(thumbnail_data._singleton == null)
thumbnail_data._singleton = new thumbnail_data();
return thumbnail_data._singleton;
};
// Return true if all thumbs in media_ids have been loaded, or are currently loading.
//
// We won't start fetching IDs that aren't loaded.
are_all_media_ids_loaded_or_loading(media_ids)
{
for(let media_id of media_ids)
{
media_id = helpers.get_media_id_first_page(media_id);
if(this.thumbnail_data[media_id] == null && !this.loading_ids[media_id])
return false;
}
return true;
}
is_media_id_loaded_or_loading(media_id)
{
media_id = helpers.get_media_id_first_page(media_id);
if(helpers.is_media_id_local(media_id) && local_api.is_media_id_loading(media_id))
return true;
return this.thumbnail_data[media_id] != null || this.loading_ids[media_id];
}
// Return thumbnail data for media_id, or null if it's not loaded.
//
// The thumbnail data won't be loaded if it's not already available. Use get_thumbnail_info
// to load thumbnail data in batches.
get_one_thumbnail_info(media_id)
{
media_id = helpers.get_media_id_first_page(media_id);
return this.thumbnail_data[media_id];
}
// Return thumbnail data for media_ids, and start loading any requested IDs that aren't
// already loaded.
get_thumbnail_info(media_ids)
{
var result = {};
var needed_media_ids = [];
let local_media_ids = [];
for(let media_id of media_ids)
{
media_id = helpers.get_media_id_first_page(media_id);
let data = this.thumbnail_data[media_id];
if(data == null)
{
// Only load illust IDs.
let { type } = helpers.parse_media_id(media_id);
if(helpers.is_media_id_local(media_id))
{
local_media_ids.push(media_id);
continue;
}
if(type != "illust")
continue;
needed_media_ids.push(media_id);
continue;
}
result[media_id] = data;
}
// If any of these are local IDs, load them with local_api.
if(local_media_ids.length)
local_api.load_media_ids(local_media_ids);
// Load any thumbnail data that we didn't have.
if(needed_media_ids.length)
this.load_thumbnail_info(needed_media_ids);
return result;
}
// Load thumbnail info for the given list of IDs.
async load_thumbnail_info(media_ids)
{
// Make a list of IDs that we're not already loading.
let illust_ids_to_load = [];
for(let media_id of media_ids)
{
media_id = helpers.get_media_id_first_page(media_id);
if(this.loading_ids[media_id] != null)
continue;
illust_ids_to_load.push(helpers.parse_media_id(media_id).id);
this.loading_ids[media_id] = true;
}
if(illust_ids_to_load.length == 0)
return;
// There's also
//
// https://www.pixiv.net/ajax/user/user_id/profile/illusts?ids[]=1&ids[]=2&...
//
// which is used by newer pages. That's useful since it tells us whether each
// image is bookmarked. However, it doesn't tell us the user's name or profile image
// URL, and for some reason it's limited to a particular user. Hopefully they'll
// have an updated generic illustration lookup call if they ever update the
// regular search pages, and we can switch to it then.
var result = await helpers.rpc_get_request("/rpc/illust_list.php", {
illust_ids: illust_ids_to_load.join(","),
// Specifying this gives us 240x240 thumbs, which we want, rather than the 150x150
// ones we'll get if we don't (though changing the URL is easy enough too).
page: "discover",
// We do our own muting, but for some reason this flag is needed to get bookmark info.
exclude_muted_illusts: 1,
});
this.loaded_thumbnail_info(result, "illust_list");
}
// Get the user's profile picture URL, or a fallback if we haven't seen it.
get_profile_picture_url(user_id)
{
let result = this.user_profile_urls[user_id];
if(!result)
result = "https://s.pximg.net/common/images/no_profile.png";
return result;
}
// Get the mapping from /ajax/user/id/illusts/bookmarks to illust_list.php's keys.
get thumbnail_info_map_illust_list()
{
if(this._thumbnail_info_map_illust_list != null)
return this._thumbnail_info_map_illust_list;
this._thumbnail_info_map_illust_list = [
["illust_id", "id"],
["url", "url"],
["tags", "tags"],
["illust_user_id", "userId"],
["illust_width", "width"],
["illust_height", "height"],
["illust_type", "illustType"],
["illust_page_count", "pageCount"],
["illust_title", "illustTitle"],
["user_profile_img", "profileImageUrl"],
["user_name", "userName"],
// illust_list.php doesn't give the creation date.
[null, "createDate"],
];
return this._thumbnail_info_map_illust_list;
};
get thumbnail_info_map_ranking()
{
if(this._thumbnail_info_map_ranking != null)
return this._thumbnail_info_map_ranking;
this._thumbnail_info_map_ranking = [
["illust_id", "id"],
["url", "url"],
["tags", "tags"],
["user_id", "userId"],
["width", "width"],
["height", "height"],
["illust_type", "illustType"],
["illust_page_count", "pageCount"],
["title", "illustTitle"],
["profile_img", "profileImageUrl"],
["user_name", "userName"],
["illust_upload_timestamp", "createDate"],
];
return this._thumbnail_info_map_ranking;
};
// This is called when we have new thumbnail data available. thumb_result is
// an array of thumbnail items.
//
// This can come from a bunch of different places, which all return the same data, but
// each in a different way:
//
// name URL
// normal /ajax/user/id/illusts/bookmarks
// illust_list illust_list.php
// following bookmark_new_illust.php
// following search.php
// rankings ranking.php
//
// We map each of these to "normal".
//
// These have the same data, but for some reason everything has different names.
// Remap them to "normal", and check that all fields we expect exist, to make it
// easier to notice if something is wrong.
loaded_thumbnail_info(thumb_result, source)
{
if(thumb_result.error)
return;
let remapped_thumb_info = null;
for(var thumb_info of thumb_result)
{
// Ignore entries with "isAdContainer". These aren't search results at all and just contain
// stuff we're not interested in.
if(thumb_info.isAdContainer)
continue;
if(source == "normal")
{
// The data is already in the format we want. The only change we make is
// to rename title to illustTitle, to match it up with illust info.
if(!("title" in thumb_info))
{
console.warn("Thumbnail info is missing key: title");
}
else
{
thumb_info.illustTitle = thumb_info.title;
delete thumb_info.title;
}
// Check that all keys we expect exist, and remove any keys we don't know about
// so we don't use them accidentally.
let thumbnail_info_map = this.thumbnail_info_map_ranking;
remapped_thumb_info = { };
for(let pair of thumbnail_info_map)
{
let key = pair[1];
if(!(key in thumb_info))
{
console.warn("Thumbnail info is missing key:", key);
continue;
}
remapped_thumb_info[key] = thumb_info[key];
}
if(!('bookmarkData' in thumb_info))
console.warn("Thumbnail info is missing key: bookmarkData");
else
{
remapped_thumb_info.bookmarkData = thumb_info.bookmarkData;
// See above.
if(remapped_thumb_info.bookmarkData != null)
delete remapped_thumb_info.bookmarkData.bookmarkId;
}
}
else if(source == "illust_list" || source == "rankings")
{
// Get the mapping for this mode.
let thumbnail_info_map =
source == "illust_list"? this.thumbnail_info_map_illust_list:
this.thumbnail_info_map_ranking;
remapped_thumb_info = { };
for(let pair of thumbnail_info_map)
{
let from_key = pair[0];
let to_key = pair[1];
if(from_key == null)
{
// This is just for illust_list createDate.
remapped_thumb_info[to_key] = null;
continue;
}
if(!(from_key in thumb_info))
{
console.warn("Thumbnail info is missing key:", from_key);
continue;
}
let value = thumb_info[from_key];
remapped_thumb_info[to_key] = value;
}
// Make sure that the illust IDs and user IDs are strings.
remapped_thumb_info.id = "" + remapped_thumb_info.id;
remapped_thumb_info.userId = "" + remapped_thumb_info.userId;
// Bookmark data is a special case.
//
// The old API has is_bookmarked: true, bookmark_id: "id" and bookmark_illust_restrict: 0 or 1.
// bookmark_id and bookmark_illust_restrict are omitted if is_bookmarked is false.
//
// The new API is a dictionary:
//
// bookmarkData = {
// bookmarkId: id,
// private: false
// }
//
// or null if not bookmarked.
//
// A couple sources of thumbnail data (bookmark_new_illust.php and search.php)
// don't return the bookmark ID. We don't use this (we only edit bookmarks from
// the image page, where we have full image data), so we omit bookmarkId from this
// data.
//
// Some pages return buggy results. /ajax/user/id/profile/all includes bookmarkData,
// but private is always false, so we can't tell if it's a private bookmark. This is
// a site bug that we can't do anything about (it affects the site too).
remapped_thumb_info.bookmarkData = null;
if(!('is_bookmarked' in thumb_info))
console.warn("Thumbnail info is missing key: is_bookmarked");
if(thumb_info.is_bookmarked)
{
remapped_thumb_info.bookmarkData = {
// See above.
// bookmarkId: thumb_info.bookmark_id,
private: thumb_info.bookmark_illust_restrict == 1,
};
}
// illustType can be a string in these instead of an int, so convert it.
remapped_thumb_info.illustType = parseInt(remapped_thumb_info.illustType);
if(source == "rankings")
{
// Rankings thumbnail info gives createDate as a Unix timestamp. Convert
// it to the same format as everything else.
let date = new Date(remapped_thumb_info.createDate*1000);
remapped_thumb_info.createDate = date.toISOString();
}
else if(source == "illust_list")
{
// This is the only source of thumbnail data that doesn't give createDate at
// all. This source is very rarely used now, so just fill in a bogus date.
remapped_thumb_info.createDate = new Date(0).toISOString();
}
}
else if(source == "internal")
{
remapped_thumb_info = thumb_info;
}
else
throw "Unrecognized source: " + source;
// "internal" is for thumbnail data which is already processed.
if(source != "internal")
{
// These fields are strings in some sources. Switch them to ints.
for(let key of ["pageCount", "width", "height"])
{
if(remapped_thumb_info[key] != null)
remapped_thumb_info[key] = parseInt(remapped_thumb_info[key]);
}
// Different APIs return different thumbnail URLs.
remapped_thumb_info.url = helpers.get_high_res_thumbnail_url(remapped_thumb_info.url);
// Create a list of thumbnail URLs.
remapped_thumb_info.previewUrls = [];
for(let page = 0; page < remapped_thumb_info.pageCount; ++page)
{
let url = helpers.get_high_res_thumbnail_url(remapped_thumb_info.url, page);
remapped_thumb_info.previewUrls.push(url);
}
// Remove url. Use previewUrl[0] instead
delete remapped_thumb_info.url;
// Rename .tags to .tagList, for consistency with the flat tag list in illust info.
remapped_thumb_info.tagList = remapped_thumb_info.tags;
delete remapped_thumb_info.tags;
}
thumb_info = remapped_thumb_info;
// Store the data.
this.add_thumbnail_info(thumb_info);
let media_id = helpers.illust_id_to_media_id(thumb_info.id);
delete this.loading_ids[media_id];
// This is really annoying: the profile picture is the only field that's present in thumbnail
// info but not illust info. We want a single basic data set for both, so that can't include
// the profile picture. But, we do want to display it in places where we can't get user
// info (muted search results), so store it separately.
if(thumb_info.profileImageUrl)
{
let profile_image_url = thumb_info.profileImageUrl;
profile_image_url = profile_image_url.replace("_50.", "_170."),
this.user_profile_urls[thumb_info.userId] = profile_image_url;
delete thumb_info.profileImageUrl;
}
}
// Broadcast that we have new thumbnail data available.
window.dispatchEvent(new Event("thumbnailsloaded"));
};
// Store thumbnail info.
add_thumbnail_info(thumb_info)
{
let media_id = helpers.illust_id_to_media_id(thumb_info.id);
this.thumbnail_data[media_id] = thumb_info;
}
is_muted(thumb_info)
{
if(muting.singleton.is_muted_user_id(thumb_info.illust_user_id))
return true;
if(muting.singleton.any_tag_muted(thumb_info.tags))
return true;
return false;
}
// This is a simpler form of thumbnail data for user info. This is just the bare minimum
// info we need to be able to show a user thumbnail on the search page. This is used when
// we're displaying lots of users in search results.
//
// We can get this info from two places, the following page (data_source_follows) and the
// user recommendations page (data_source_discovery_users). Of course, since Pixiv never
// does anything the same way twice, they have different formats.
//
// The only info we need is:
// userId
// userName
// profileImageUrl
add_quick_user_data(source_data, source)
{
let data = null;
let id = source_data.userId;
if(source == "following")
{
data = {
userId: source_data.userId,
userName: source_data.userName,
profileImageUrl: source_data.profileImageUrl,
};
}
else if(source == "recommendations")
{
data = {
userId: source_data.userId,
userName: source_data.name,
profileImageUrl: source_data.imageBig,
};
}
else if(source == "users_bookmarking_illust" || source == "user_search")
{
data = {
userId: source_data.user_id,
userName: source_data.user_name,
profileImageUrl: source_data.profile_img,
};
}
else
throw "Unknown source: " + source;
this.quick_user_data[data.userId] = data;
}
get_quick_user_data(user_id)
{
return this.quick_user_data[user_id];
}
thumbnail_info_keys = [
"id",
"illustType",
"illustTitle",
"pageCount",
"userId",
"userName",
"width",
"height",
"previewUrls",
"bookmarkData",
"createDate",
"tagList",
];
// Return illust info or thumbnail data, whicihever is available. If we don't have
// either, read full illust info. If we have both, return illust info.
//
// This is used when we're displaying info for a single image, and the caller only
// needs thumbnail data. It allows us to use either thumbnail data or illust info,
// so we can usually return the data immediately.
//
// If it isn't available and we need to load it, we load illust info instead of thumbnail
// data, since it takes a full API request either way.
async get_or_load_illust_data(media_id)
{
// First, see if we have full illust info. Prefer to use it over thumbnail info
// if we have it, so full info is available. If we don't, see if we have thumbnail
// info.
let data = image_data.singleton().get_media_info_sync(media_id);
if(data == null)
data = thumbnail_data.singleton().get_one_thumbnail_info(media_id);
// If we don't have either, load the image info.
if(data == null)
data = await image_data.singleton().get_media_info(media_id);
this._check_illust_data(data);
return data;
}
// A sync version of get_or_load_illust_data. This doesn't load data if it
// isn't available.
get_illust_data_sync(media_id)
{
// First, see if we have full illust info. Prefer to use it over thumbnail info
// if we have it, so full info is available. If we don't, see if we have thumbnail
// info.
let data = image_data.singleton().get_media_info_sync(media_id);
if(data == null)
data = thumbnail_data.singleton().get_one_thumbnail_info(media_id);
this._check_illust_data(data);
return data;
}
// Check the result of get_or_load_illust_data. We always expect all keys in
// thumbnail_info_keys to be included, regardless of where the data came from.
_check_illust_data(illust_data)
{
if(illust_data == null)
return;
for(let key of this.thumbnail_info_keys)
{
if(!(key in illust_data))
{
console.warn(\`Missing key \${key} for early data\`, illust_data);
continue;
}
}
}
// Update illustration data in both thumbnail info and illust info.
//
// This is used in places that use get_or_load_illust_data to get thumbnail
// info, and then need to save changes to it. Update both sources.
//
// This can't update tags.
update_illust_data(media_id, data)
{
media_id = helpers.get_media_id_first_page(media_id);
let update_data = (update, keys) => {
for(let key of keys)
{
if(!(key in data))
continue;
console.assert(key != "tags");
update[key] = data[key];
}
};
let thumb_data = thumbnail_data.singleton().get_one_thumbnail_info(media_id);
if(thumb_data)
update_data(thumb_data, this.thumbnail_info_keys);
let illust_info = image_data.singleton().get_media_info_sync(media_id);
if(illust_info != null)
update_data(illust_info, this.thumbnail_info_keys);
image_data.singleton().call_illust_modified_callbacks(media_id);
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/thumbnail_data.js
`;
ppixiv.resources["src/manga_thumbnail_widget.js"] = `"use strict";
class scroll_handler
{
constructor(container)
{
this.container = container;
}
// Bring item into view. We'll also try to keep the next and previous items visible.
scroll_into_view(item)
{
// Make sure item is a direct child of the container.
if(item.parentNode != this.container)
{
console.error("Node", item, "isn't in scroller", this.container);
return;
}
// Scroll so the items to the left and right of the current thumbnail are visible,
// so you can tell whether there's another entry to scroll to. If we can't fit
// them, center the selection.
var scroller_left = this.container.getBoundingClientRect().left;
var left = item.offsetLeft - scroller_left;
if(item.previousElementSibling)
left = Math.min(left, item.previousElementSibling.offsetLeft - scroller_left);
var right = item.offsetLeft + item.offsetWidth - scroller_left;
if(item.nextElementSibling)
right = Math.max(right, item.nextElementSibling.offsetLeft + item.nextElementSibling.offsetWidth - scroller_left);
var new_left = this.container.scrollLeft;
if(new_left > left)
new_left = left;
if(new_left + this.container.offsetWidth < right)
new_left = right - this.container.offsetWidth;
this.container.scrollLeft = new_left;
// If we didn't fit the previous and next entries, there isn't enough space. This
// might be a wide thumbnail or the window might be very narrow. Just center the
// selection. Note that we need to compare against the value we assigned and not
// read scrollLeft back, since the API is broken and reads back the smoothed value
// rather than the target we set.
if(new_left > left ||
new_left + this.container.offsetWidth < right)
{
this.center_item(item);
}
}
// Scroll the given item to the center.
center_item(item)
{
var scroller_left = this.container.getBoundingClientRect().left;
var left = item.offsetLeft - scroller_left;
left += item.offsetWidth/2;
left -= this.container.offsetWidth / 2;
this.container.scrollLeft = left;
}
/* Snap to the target position, cancelling any smooth scrolling. */
snap()
{
this.container.style.scrollBehavior = "auto";
if(this.container.firstElementChild)
this.container.firstElementChild.getBoundingClientRect();
this.container.getBoundingClientRect();
this.container.style.scrollBehavior = "";
}
};
ppixiv.manga_thumbnail_widget = class extends ppixiv.widget
{
constructor({...options})
{
super({options, template: \`
\`});
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;
let media_id = helpers.illust_id_to_media_id(this.illust_info.illustId, new_page);
main_controller.singleton.show_media(media_id);
/*
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);
let media_id = helpers.illust_id_to_media_id(new_illust_id, new_page);
main_controller.singleton.show_media(media_id);
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");
let entry = this.create_template({name: "thumb", html: \`
\`});
entry.dataset.page = page;
entry.querySelector("img.manga-thumb").src = url;
ul.appendChild(entry);
this.entries.push(entry);
}
var right_padding = document.createElement("div");
right_padding.style.flex = "1";
ul.appendChild(right_padding);
// Place the cursor inside the first entry, so it follows it around as we scroll.
this.entries[0].appendChild(this.cursor);
this.update_cursor_position();
}
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/manga_thumbnail_widget.js
`;
ppixiv.resources["src/page_manager.js"] = `"use strict";
// This handles:
//
// - Keeping track of whether we're active or not. If we're inactive, we turn off
// and let the page run normally.
// - Storing state in the address bar.
//
// We're active by default on illustration pages, and inactive by default on others.
//
// If we're active, we'll store our state in the hash as "#ppixiv/...". The start of
// the hash will always be "#ppixiv", so we can tell it's our data. If we're on a page
// where we're inactive by default, this also remembers that we've been activated.
//
// If we're inactive on a page where we're active by default, we'll always put something
// other than "#ppixiv" in the address bar. It doesn't matter what it is. This remembers
// that we were deactivated, and remains deactivated even if the user clicks an anchor
// in the page that changes the hash.
//
// If we become active or inactive after the page loads, we refresh the page.
//
// We have two sets of query parameters: args stored in the URL query, and args stored in
// the hash. For example, in:
//
// https://www.pixiv.net/bookmark.php?p=2#ppixiv?illust_id=1234
//
// our query args are p=2, and our hash args are illust_id=1234. We use query args to
// store state that exists in the underlying page, and hash args to store state that
// doesn't, so the URL remains valid for the actual Pixiv page if our UI is turned off.
ppixiv.page_manager = class
{
constructor()
{
this.window_popstate = this.window_popstate.bind(this);
window.addEventListener("popstate", this.window_popstate, true);
this.data_sources_by_canonical_url = {};
this.active = this._active_internal();
};
// Return the singleton, creating it if needed.
static singleton()
{
if(page_manager._singleton == null)
page_manager._singleton = new page_manager();
return page_manager._singleton;
};
// Return the data source for a URL, or null if the page isn't supported.
get_data_source_for_url(url)
{
// url is usually document.location, which for some reason doesn't have .searchParams.
var url = new unsafeWindow.URL(url);
url = helpers.get_url_without_language(url);
let first_part = helpers.get_page_type_from_url(url);
if(first_part == "artworks")
{
return data_sources.current_illust;
}
else if(first_part == "users")
{
// This is one of:
//
// /users/12345
// /users/12345/artworks
// /users/12345/illustrations
// /users/12345/manga
// /users/12345/bookmarks
// /users/12345/following
//
// All of these except for bookmarks are handled by data_sources.artist.
let mode = helpers.get_path_part(url, 2);
if(mode == "following")
return data_sources.follows;
if(mode != "bookmarks")
return data_sources.artist;
// Handle a special case: we're called by early_controller just to find out if
// the current page is supported or not. This happens before window.global_data
// exists, so we can't check if we're viewing our own bookmarks or someone else's.
// In this case we don't need to, since the caller just wants to see if we return
// a data source or not.
if(window.global_data == null)
return data_sources.bookmarks;
// If show-all=0 isn't in the hash, and we're not viewing someone else's bookmarks,
// we're viewing all bookmarks, so use data_sources.bookmarks_merged. Otherwise,
// use data_sources.bookmarks.
var args = new helpers.args(url);
var user_id = helpers.get_path_part(url, 1);
if(user_id == null)
user_id = window.global_data.user_id;
var viewing_own_bookmarks = user_id == window.global_data.user_id;
var both_public_and_private = viewing_own_bookmarks && args.hash.get("show-all") != "0";
return both_public_and_private? data_sources.bookmarks_merged:data_sources.bookmarks;
}
else if(url.pathname == "/new_illust.php" || url.pathname == "/new_illust_r18.php")
return data_sources.new_illust;
else if(url.pathname == "/bookmark_new_illust.php" || url.pathname == "/bookmark_new_illust_r18.php")
return data_sources.bookmarks_new_illust;
else if(url.pathname == "/history.php")
return data_sources.recent;
else if(first_part == "tags")
return data_sources.search;
else if(url.pathname == "/discovery")
return data_sources.discovery;
else if(url.pathname == "/discovery/users")
return data_sources.discovery_users;
else if(url.pathname == "/bookmark_detail.php")
{
// If we've added "recommendations" to the hash info, this was a recommendations link.
let args = new helpers.args(url);
if(args.hash.get("recommendations"))
return data_sources.related_illusts;
else
return data_sources.related_favorites;
}
else if(url.pathname == "/ranking.php")
return data_sources.rankings;
else if(url.pathname == "/search_user.php")
return data_sources.search_users;
else if(url.pathname.startsWith("/request/complete"))
return data_sources.completed_requests;
else if(url.pathname.startsWith(local_api.path))
return data_sources.vview;
else
return null;
};
// Create the data source for a given URL.
//
// If we've already created a data source for this URL, the same one will be
// returned.
//
// If force is true, we'll always create a new data source, replacing any
// previously created one.
create_data_source_for_url(url, force)
{
var data_source_class = this.get_data_source_for_url(url);
if(data_source_class == null)
{
console.error("Unexpected path:", url.pathname);
return;
}
// Canonicalize the URL to see if we already have a data source for this URL.
let canonical_url = data_source_class.get_canonical_url(url);
// console.log("url", url.toString(), "becomes", canonical_url);
if(!force && canonical_url in this.data_sources_by_canonical_url)
{
// console.log("Reusing data source for", url.toString());
return this.data_sources_by_canonical_url[canonical_url];
}
// console.log("Creating new data source for", url.toString());
var source = new data_source_class(url.href);
this.data_sources_by_canonical_url[canonical_url] = source;
return source;
}
// If we have the given data source cached, discard it, so it'll be recreated
// the next time it's used.
discard_data_source(data_source)
{
let urls_to_remove = [];
for(let url in this.data_sources_by_canonical_url)
{
if(this.data_sources_by_canonical_url[url] === data_source)
urls_to_remove.push(url);
}
for(let url of urls_to_remove)
delete this.data_sources_by_canonical_url[url];
}
// Return true if it's possible for us to be active on this page.
available_for_url(url)
{
// We support the page if it has a data source.
return this.get_data_source_for_url(url) != null;
};
window_popstate(e)
{
var currently_active = this._active_internal();
if(this.active == currently_active)
return;
// Stop propagation, so other listeners don't see this. For example, this prevents
// the thumbnail viewer from turning on or off as a result of us changing the hash
// to "#no-ppixiv".
e.stopImmediatePropagation();
if(this.active == currently_active)
return;
this.store_ppixiv_disabled(!currently_active);
console.log("Active state changed");
// The URL has changed and caused us to want to activate or deactivate. Reload the
// page.
//
// We'd prefer to reload with cache, like a regular navigation, but Firefox seems
// to reload without cache no matter what we do, even though document.location.reload
// is only supposed to bypass cache on reload(true). There doesn't seem to be any
// reliable workaround.
document.location.reload();
}
store_ppixiv_disabled(disabled)
{
// Remember that we're enabled or disabled in this tab.
if(disabled)
window.sessionStorage.ppixiv_disabled = 1;
else
delete window.sessionStorage.ppixiv_disabled;
}
// Return true if we're active by default on the current page.
active_by_default()
{
if(ppixiv.native)
return true;
// If the disabled-by-default setting is enabled, disable by default until manually
// turned on.
if(settings.get("disabled-by-default"))
return false;
// If this is set, the user clicked the "return to Pixiv" button. Stay disabled
// in this tab until we're reactivated.
if(window.sessionStorage.ppixiv_disabled)
return false;
return this.available_for_url(ppixiv.location);
};
// Return true if we're currently active.
//
// This is cached at the start of the page and doesn't change unless the page is reloaded.
_active_internal()
{
// If the hash is empty, use the default.
if(ppixiv.location.hash == "")
return this.active_by_default();
// If we have a hash and it's not #ppixiv, then we're explicitly disabled. If we
// # do have a #ppixiv hash, we're explicitly enabled.
//
// If we're explicitly enabled but aren't actually available, we're disabled. This
// makes sure we don't break pages if we accidentally load them with a #ppixiv hash,
// or if we remove support for a page that people have in their browser session.
return helpers.is_ppixiv_url(ppixiv.location) && this.available_for_url(ppixiv.location);
};
// Given a list of tags, return the URL to use to search for them. This differs
// depending on the current page.
get_url_for_tag_search(tags, url)
{
url = helpers.get_url_without_language(url);
let type = helpers.get_page_type_from_url(url);
if(type == "tags")
{
// If we're on search already, just change the search tag, so we preserve other settings.
// /tags/tag/artworks -> /tag/new tag/artworks
let parts = url.pathname.split("/");
parts[2] = encodeURIComponent(tags);
url.pathname = parts.join("/");
} else {
// If we're not, change to search and remove the rest of the URL.
url = new URL("/tags/" + encodeURIComponent(tags) + "/artworks#ppixiv", url);
}
// Don't include things like the current page in the URL.
url = data_source.remove_ignored_url_parts(url);
return url;
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/page_manager.js
`;
ppixiv.resources["src/remove_link_interstitial.js"] = `"use strict";
// Fix Pixiv's annoying link interstitials.
//
// External links on Pixiv go through a pointless extra page. This seems like
// they're trying to mask the page the user is coming from, but that's what
// rel=noreferrer is for. Search for these links and fix them.
//
// This also removes target=_blank, which is just obnoxious. If I want a new
// tab I'll middle click.
(function() {
// Ignore iframes.
if(window.top != window.self)
return;
var observer = new window.MutationObserver(function(mutations) {
for(var mutation of mutations) {
if(mutation.type != 'childList')
return;
for(var node of mutation.addedNodes)
{
if(node.querySelectorAll == null)
continue;
helpers.fix_pixiv_links(node);
}
}
});
window.addEventListener("DOMContentLoaded", function() {
helpers.fix_pixiv_links(document.body);
observer.observe(window.document.body, {
// We could listen to attribute changes so we'll fix links that have their
// target changed after they're added to the page, but unless there are places
// where that's needed, let's just listen to node additions so we don't trigger
// too often.
attributes: false,
childList: true,
subtree: true
});
}, true);
})();
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/remove_link_interstitial.js
`;
ppixiv.resources["src/image_preloading.js"] = `"use strict";
// Handle preloading images.
//
// If we have a reasonably fast connection and the site is keeping up, we can just preload
// blindly and let the browser figure out priorities. However, if we preload too aggressively
// for the connection and loads start to back up, it can cause image loading to become delayed.
// For example, if we preload 100 manga page images, and then back out of the page and want to
// view something else, the browser won't load anything else until those images that we no
// longer need finish loading.
//
// image_preloader is told the media_id that we're currently showing, and the ID that we want
// to speculatively load. We'll run loads in parallel, giving the current image's resources
// priority and cancelling loads when they're no longer needed.
// A base class for fetching a single resource:
class preloader
{
constructor()
{
this.abort_controller = new AbortController();
}
// Cancel the fetch.
cancel()
{
if(this.abort_controller == null)
return;
this.abort_controller.abort();
this.abort_controller = null;
}
}
// Load a single image with
:
class img_preloader extends preloader
{
constructor(url, onerror=null)
{
super();
this.url = url;
this.onerror = onerror;
console.assert(url);
}
// Start the fetch. This should only be called once.
async start()
{
if(this.url == null)
return;
let img = document.createElement("img");
img.src = this.url;
let result = await helpers.wait_for_image_load(img, this.abort_controller.signal);
if(result == "failed" && this.onerror)
this.onerror();
}
}
// Load a resource with fetch.
class fetch_preloader extends preloader
{
constructor(url)
{
super();
this.url = url;
console.assert(url);
}
async start()
{
if(this.url == null)
return;
let request = helpers.send_pixiv_request({
url: this.url,
method: "GET",
signal: this.abort_controller.signal,
});
// Wait for the body to download before completing. Ignore errors here (they'll
// usually be cancellations).
try {
request = await request;
await request.text();
} catch(e) { }
}
}
// The image preloader singleton.
ppixiv.image_preloader = class
{
// Return the singleton, creating it if needed.
static get singleton()
{
if(image_preloader._singleton == null)
image_preloader._singleton = new image_preloader();
return image_preloader._singleton;
};
constructor()
{
// The _preloader objects that we're currently running.
this.preloads = [];
// A queue of URLs that we've finished preloading recently. We use this to tell if
// we don't need to run a preload.
this.recently_preloaded_urls = [];
}
// Set the media_id the user is currently viewing. If media_id is null, the user isn't
// viewing an image (eg. currently viewing thumbnails).
async set_current_image(media_id)
{
if(this.current_media_id == media_id)
return;
this.current_media_id = media_id;
this.current_illust_info = null;
await this.guess_preload(media_id);
if(this.current_media_id == null)
return;
// Get the image data. This will often already be available.
let illust_info = await image_data.singleton().get_media_info(this.current_media_id);
// Stop if the illust was changed while we were loading.
if(this.current_media_id != media_id)
return;
// Store the illust_info for current_media_id.
this.current_illust_info = illust_info;
this.check_fetch_queue();
}
// Set the media_id we want to speculatively load, which is the next or previous image in
// the current search. If media_id is null, we don't want to speculatively load anything.
async set_speculative_image(media_id)
{
if(this.speculative_media_id == media_id)
return;
this.speculative_media_id = media_id;
this.speculative_illust_info = null;
if(this.speculative_media_id == null)
return;
// Get the image data. This will often already be available.
let illust_info = await image_data.singleton().get_media_info(this.speculative_media_id);
if(this.speculative_media_id != media_id)
return;
// Stop if the illust was changed while we were loading.
if(this.speculative_media_id != media_id)
return;
// Store the illust_info for current_media_id.
this.speculative_illust_info = illust_info;
this.check_fetch_queue();
}
// See if we need to start or stop preloads. We do this when we have new illustration info,
// and when a fetch finishes.
check_fetch_queue()
{
// console.log("check queue:", this.current_illust_info != null, this.speculative_illust_info != null);
// Make a list of fetches that we want to be running, in priority order.
let wanted_preloads = [];
if(this.current_illust_info != null)
wanted_preloads = wanted_preloads.concat(this.create_preloaders_for_illust(this.current_illust_info, this.current_media_id));
if(this.speculative_illust_info != null)
wanted_preloads = wanted_preloads.concat(this.create_preloaders_for_illust(this.speculative_illust_info, this.speculative_media_id));
// Remove all preloads from wanted_preloads that we've already finished recently.
let filtered_preloads = [];
for(let preload of wanted_preloads)
{
if(this.recently_preloaded_urls.indexOf(preload.url) == -1)
filtered_preloads.push(preload);
}
// If we don't want any preloads, stop. If we have any running preloads, let them continue.
if(filtered_preloads.length == 0)
{
// console.log("Nothing to do");
return;
}
// Discard preloads beyond the number we want to be running. If we're loading more than this,
// we'll start more as these finish.
let concurrent_preloads = 5;
filtered_preloads.splice(concurrent_preloads);
// console.log("Preloads:", filtered_preloads.length);
// If any preload in the list is running, stop. We only run one preload at a time, so just
// let it finish.
let any_preload_running = false;
for(let preload of filtered_preloads)
{
let active_preload = this._find_active_preload_by_url(preload.url);
if(active_preload != null)
return;
}
// No preloads are running, so start the highest-priority preload.
//
// updated_preload_list allows us to run multiple preloads at a time, but we currently
// run them in serial.
let unwanted_preloads;
let updated_preload_list = [];
for(let preload of filtered_preloads)
{
// Start this preload.
// console.log("Start preload:", preload.url);
let promise = preload.start();
let aborted = false;
promise.catch((e) => {
if(e.name == "AbortError")
aborted = true;
});
promise.finally(() => {
// Add the URL to recently_preloaded_urls, so we don't try to preload this
// again for a while. We do this even on error, so we don't try to load
// failing images repeatedly.
//
// Don't do this if the request was aborted, since that just means the user
// navigated away.
if(!aborted)
{
this.recently_preloaded_urls.push(preload.url);
this.recently_preloaded_urls.splice(0, this.recently_preloaded_urls.length - 1000);
}
// When the preload finishes (successful or not), remove it from the list.
let idx = this.preloads.indexOf(preload);
if(idx == -1)
{
console.error("Preload finished, but we weren't running it:", preload.url);
return;
}
this.preloads.splice(idx, 1);
// See if we need to start another preload.
this.check_fetch_queue();
});
updated_preload_list.push(preload);
break;
}
// Cancel preloads in this.preloads that aren't in updated_preload_list. These are
// preloads that we either don't want anymore, or which have been pushed further down
// the priority queue and overridden.
for(let preload of this.preloads)
{
if(updated_preload_list.indexOf(preload) != -1)
continue;
console.log("Cancelling preload:", preload.url);
preload.cancel();
// Preloads stay in the list until the cancellation completes.
updated_preload_list.push(preload);
}
this.preloads = updated_preload_list;
}
// Return the preloader if we're currently preloading url.
_find_active_preload_by_url(url)
{
for(let preload of this.preloads)
if(preload.url == url)
return preload;
return null;
}
// Return an array of preloaders to load resources for the given illustration.
create_preloaders_for_illust(illust_data, media_id)
{
// Don't precache muted images.
if(muting.singleton.any_tag_muted(illust_data.tagList))
return [];
if(muting.singleton.is_muted_user_id(illust_data.userId))
return [];
// If this is an animation, preload the ZIP.
if(illust_data.illustType == 2 && !helpers.is_media_id_local(media_id))
{
let results = [];
results.push(new fetch_preloader(illust_data.ugoiraMetadata.originalSrc));
// Preload the original image too, which viewer_ugoira displays if the ZIP isn't
// ready yet.
results.push(new img_preloader(illust_data.urls.original));
return results;
}
// If this is a video, preload the poster.
if(illust_data.illustType == "video")
return [new img_preloader(illust_data.mangaPages[0].urls.poster) ];
// Otherwise, preload the images. Preload thumbs first, since they'll load
// much faster.
let results = [];
for(let url of illust_data.previewUrls)
results.push(new img_preloader(url));
// Preload the requested page.
let page = helpers.parse_media_id(media_id).page;
if(page < illust_data.mangaPages.length)
results.push(new img_preloader(illust_data.mangaPages[page].urls.original));
// Preload the remaining pages.
for(let p = 0; p < illust_data.mangaPages.length; ++p)
{
if(p == page)
continue;
results.push(new img_preloader(illust_data.mangaPages[p].urls.original));
}
return results;
}
// Try to start a guessed preload.
//
// This uses guess_image_url to try to figure out the image URL earlier. Normally
// we have to wait for the image info request to finish before we have the image URL
// to start loading, but if we can guess the URL correctly then we can start loading
// it immediately.
//
// If media_id is null, stop any running guessed preload.
async guess_preload(media_id)
{
// See if we can guess the image's URL from previous info, or if we can figure it
// out from another source.
let guessed_url = null;
if(media_id != null)
{
guessed_url = await guess_image_url.get.guess_url(media_id);
if(this.guessed_preload && this.guessed_preload.url == guessed_url)
return;
}
// Cancel any previous guessed preload.
if(this.guessed_preload)
{
this.guessed_preload.cancel();
this.guessed_preload = null;
}
// Start the new guessed preload.
if(guessed_url)
{
this.guessed_preload = new img_preloader(guessed_url, () => {
// The image load failed. Let guessed_preload know.
console.info("Guessed image load failed");
guess_image_url.get.guessed_url_incorrect(media_id);
});
this.guessed_preload.start();
}
}
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/image_preloading.js
`;
ppixiv.resources["src/whats_new.js"] = `"use strict";
// This should be inside whats_new, but Firefox is in the dark ages and doesn't support class fields.
let _update_history = [
{
version: 126,
text: \`
Muted tags and users can now be edited from the preferences menu.
Any number of tags can be muted. If you don't have Premium, mutes will be
saved to the browser instead of to your Pixiv account.
\`,
}, {
version: 123,
text: \`
Added support for viewing completed requests.
Disabled light mode for now. It's a pain to maintain two color schemes and everyone
is probably using dark mode anyway. If you really want it, let me know on GitHub.
\`,
},
{
version: 121,
text: \`
Added a slideshow mode. Click
wallpaper at the top.
Added an option to pan images as they're viewed.
Double-clicking images now toggles fullscreen.
The background is now fully black when viewing an image, for better contrast. Other screens are still dark grey.
Added an option to bookmark privately by default, such as when bookmarking by selecting
a bookmark tag.
Reworked the animation UI.
\`,
},
{
version: 117,
text: \`
Added Linked Tabs. Enable linked tabs in preferences to show images
on more than one monitor as they're being viewed (try it with a portrait monitor).
Showing the popup menu when Ctrl is pressed is now optional.
\`,
},
{
version: 112,
text: \`
Added Send to Tab to the context menu, which allows quickly sending an image to
another tab.
Added a More Options dropdown to the popup menu. This includes some things that
were previously only available from the hover UI. Send to Tab is also in here.
Disabled the "Similar Illustrations" lightbulb button on thumbnails. It can now be
accessed from the popup menu, along with a bunch of other ways to get image recommendations.
\`
},
{
version: 110,
text: \`
Added Quick View. This views images immediately when the mouse is pressed,
and images can be panned with the same press.
This can be enabled in preferences, and may become the default in a future release.
\`
},
{
version: 109,
boring: true,
text: \`Added a visual marker on thumbnails to show the last image you viewed.\`
},
{
version: 104,
text:
"Bookmarks can now be shuffled, to view them in random order. " +
"
" +
"Bookmarking an image now always likes it, like Pixiv's mobile app. " +
"(Having an option for this didn't seem useful.)" +
"
" +
"Added a Recent History search, to show recent search results. This can be turned " +
"off in settings."
},
{
version: 102,
boring: true,
text:
"Animations now start playing much faster."
},
{
version: 100,
text:
"Enabled auto-liking images on bookmark by default, to match the Pixiv mobile apps. " +
"If you've previously changed this in preferences, your setting should stay the same." +
"
" +
"Added a download button for the current page when viewing manga posts."
},
{
version: 97,
text:
"Holding Ctrl now displays the popup menu, to make it easier to use for people on touchpads.
" +
"
" +
"Keyboard hotkeys reworked, and can now be used while hovering over search results.
" +
"
" +
"Ctrl-V - like image\\n" +
"Ctrl-B - bookmark\\n" +
"Ctrl-Alt-B - bookmark privately\\n" +
"Ctrl-Shift-B - remove bookmark\\n" +
"Ctrl-Alt-Shift-M - add bookmark tag\\n" +
"Ctrl-F - follow\\n" +
"Ctrl-Alt-F - follow privately\\n" +
"Ctrl-Shift-F - unfollow\\n" +
"
"
},
{
version: 89,
text:
"Reworked zooming to make it more consistent and easier to use." +
"
" +
"You can now zoom images to 100% to view them at actual size."
},
{
version: 82,
text:
"Press Ctrl-Alt-Shift-B to bookmark an image with a new tag."
},
{
version: 79,
text:
"Added support for viewing new R-18 works by followed users."
},
{
version: 77,
text:
"Added user searching." +
"
" +
"Commercial/subscription links in user profiles (Fanbox, etc.) now use a different icon."
},
{
version: 74,
text:
"Viewing your followed users by tag is now supported." +
"
" +
"You can now view other people who bookmarked an image, to see what else they've bookmarked. " +
"This is available from the top-left hover menu."
},
{
version: 72,
text:
"The followed users page now remembers which page you were on if you reload the page, to make " +
"it easier to browse your follows if you have a lot of them." +
"
" +
"Returning to followed users now flashes who you were viewing like illustrations do," +
"to make it easier to pick up where you left off." +
"
" +
"Added a browser back button to the context menu, to make navigation easier in fullscreen " +
"when the browser back button isn't available."
},
{
version: 68,
text:
"You can now go to either the first manga page or the page list from search results. " +
"Click the image to go to the first page, or the page count to go to the page list." +
"
" +
"Our button is now in the bottom-left when we're disabled, since Pixiv now puts a menu " +
"button in the top-left and we were covering it up."
},
{
version: 65,
text:
"Bookmark viewing now remembers which page you were on if the page is reloaded." +
"
"+
"Zooming is now in smaller increments, to make it easier to zoom to the level you want."
},
{
version: 57,
text:
"Search for similar artists. Click the recommendations item at the top of the artist page, " +
"or in the top-left when viewing an image." +
"
"+
"You can also now view suggested artists."
},
{
version: 56,
text:
"Tag translations are now supported. This can be turned off in preferences. " +
"
" +
"Added quick tag search editing. After searching for a tag, click the edit button " +
"to quickly add and remove tags."
},
{
version: 55,
text:
"The \\"original\\" view is now available in Rankings." +
"
" +
"Hiding the mouse cursor can now be disabled in preferences.",
},
{
version: 49,
text:
"Add \\"Hover to show UI\\" preference, which is useful for low-res monitors."
},
{
version: 47,
text:
"You can now view the users you're following with \\"Followed Users\\". This shows each " +
"user's most recent post."
},
];
ppixiv.whats_new = class extends ppixiv.dialog_widget
{
// Return the newest revision that exists in history. This is always the first
// history entry.
static latest_history_revision()
{
return _update_history[0].version;
}
// Return the latest interesting history entry.
//
// We won't highlight the "what's new" icon for boring history entries.
static latest_interesting_history_revision()
{
for(let history of _update_history)
{
if(history.boring)
continue;
return history.version;
}
// We shouldn't get here.
throw Error("Couldn't find anything interesting");
}
constructor({...options})
{
super({...options, visible: true, template: \`
\`});
this.refresh();
this.container.querySelector(".close-button").addEventListener("click", (e) => { this.visible = false;; });
// Close if the container is clicked, but not if something inside the container is clicked.
this.container.addEventListener("click", (e) => {
if(e.target != this.container)
return;
this.visible = false;
});
// Hide on any state change.
window.addEventListener("popstate", (e) => {
this.visible = false;
});
}
refresh()
{
let items_box = this.container.querySelector(".items");
// Not really needed, since our contents never change
helpers.remove_elements(items_box);
for(let update of _update_history)
{
let entry = this.create_template({name: "item", html: \`
\`});
entry.querySelector(".rev").innerText = "r" + update.version;
entry.querySelector(".text").innerHTML = update.text;
items_box.appendChild(entry);
}
}
visibility_changed()
{
super.visibility_changed();
if(!this.visible)
{
// Remove the widget when it's hidden.
this.container.remove();
}
}
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/whats_new.js
`;
ppixiv.resources["src/send_image.js"] = `"use strict";
// This handles sending images from one tab to another.
ppixiv.SendImage = class
{
// This is a singleton, so we never close this channel.
static send_image_channel = new BroadcastChannel("ppixiv:send-image");
// A UUID we use to identify ourself to other tabs:
static tab_id = this.create_tab_id();
static tab_id_tiebreaker = Date.now()
static create_tab_id(recreate=false)
{
// If we have a saved tab ID, use it.
if(!recreate && sessionStorage.ppixivTabId)
return sessionStorage.ppixivTabId;
// Make a new ID, and save it to the session. This helps us keep the same ID
// when we're reloaded.
sessionStorage.ppixivTabId = helpers.create_uuid();
return sessionStorage.ppixivTabId;
}
static known_tabs = {};
static initialized = false;
static init()
{
if(this.initialized)
return;
this.initialized = true;
this.broadcast_tab_info = this.broadcast_tab_info.bind(this);
this.pending_movement = [0, 0];
this.listeners = {};
window.addEventListener("unload", this.window_onunload.bind(this));
// Let other tabs know when the info we send in tab info changes. For resize, delay this
// a bit so we don't spam broadcasts while the user is resizing the window.
window.addEventListener("resize", (e) => {
if(this.broadcast_info_after_resize_timer != -1)
clearTimeout(this.broadcast_info_after_resize_timer);
this.broadcast_info_after_resize_timer = setTimeout(this.broadcast_tab_info, 250);
});
window.addEventListener("visibilitychange", this.broadcast_tab_info);
document.addEventListener("windowtitlechanged", this.broadcast_tab_info);
// Send on window focus change, so we update things like screenX/screenY that we can't
// monitor.
window.addEventListener("focus", this.broadcast_tab_info);
window.addEventListener("blur", this.broadcast_tab_info);
window.addEventListener("popstate", this.broadcast_tab_info);
// If we gain focus while quick view is active, finalize the image. Virtual
// history isn't meant to be left enabled, since it doesn't interact with browser
// history.
window.addEventListener("focus", (e) => {
let args = ppixiv.helpers.args.location;
if(args.hash.has("temp-view"))
{
console.log("Finalizing quick view image because we gained focus");
args.hash.delete("virtual");
args.hash.delete("temp-view");
ppixiv.helpers.set_page_url(args, false, "navigation");
}
});
SendImage.send_image_channel.addEventListener("message", this.received_message.bind(this));
this.broadcast_tab_info();
this.query_tabs();
}
static messages = new EventTarget();
static add_message_listener(message, func)
{
if(!this.listeners[message])
this.listeners[message] = [];
this.listeners[message].push(func);
}
// If we're sending an image and the page is unloaded, try to cancel it. This is
// only registered when we're sending an image.
static window_onunload(e)
{
// Tell other tabs that this tab has closed.
SendImage.send_message({ message: "tab-closed" });
}
static query_tabs()
{
SendImage.send_message({ message: "list-tabs" });
}
// Send an image to another tab. action is either "temp-view", to show the image temporarily,
// or "display", to navigate to it.
static async send_image(media_id, tab_ids, action)
{
// Send everything we know about the image, so the receiver doesn't have to
// do a lookup.
let thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(media_id);
let illust_data = image_data.singleton().get_media_info_sync(media_id);
let user_id = illust_data?.userId;
let user_info = user_id? image_data.singleton().get_user_info_sync(user_id):null;
this.send_message({
message: "send-image",
from: SendImage.tab_id,
to: tab_ids,
media_id: media_id,
action: action, // "temp-view" or "display"
thumbnail_info: thumbnail_info,
illust_data: illust_data,
user_info: user_info,
}, false);
}
static received_message(e)
{
let data = e.data;
// If this message has a target and it's not us, ignore it.
if(data.to && data.to.indexOf(SendImage.tab_id) == -1)
return;
let event = new Event(data.message);
event.message = data;
this.messages.dispatchEvent(event);
// Call any listeners for this message.
if(this.listeners[data.message])
{
for(let func of this.listeners[data.message])
func(data);
}
if(data.message == "tab-info")
{
// Info about a new tab, or a change in visibility.
//
// This may contain thumbnail and illust info. We don't register it here. It
// can be used explicitly when we're displaying a tab thumbnail, but each tab
// might have newer or older image info, and propagating them back and forth
// could be confusing.
if(data.from == SendImage.tab_id)
{
// The other tab has the same ID we do. The only way this normally happens
// is if a tab is duplicated, which will duplicate its sessionStorage with it.
// If this happens, use tab_id_tiebreaker to decide who wins. The tab with
// the higher value will recreate its tab ID. This is set to the time when
// we're loaded, so this will usually cause new tabs to be the one to create
// a new ID.
if(SendImage.tab_id_tiebreaker >= data.tab_id_tiebreaker)
{
console.log("Creating a new tab ID due to ID conflict");
SendImage.tab_id = SendImage.create_tab_id(true /* recreate */ );
}
else
console.log("Tab ID conflict (other tab will create a new ID)");
// Broadcast info. If we recreated our ID then we want to broadcast it on the
// new ID. If we didn't, we still want to broadcast it to replace the info
// the other tab just sent on our ID.
this.broadcast_tab_info();
}
this.known_tabs[data.from] = data;
}
else if(data.message == "tab-closed")
{
delete this.known_tabs[data.from];
}
else if(data.message == "list-tabs")
{
// A new tab is populating its tab list.
this.broadcast_tab_info();
}
else if(data.message == "send-image")
{
// If this message has illust info or thumbnail info, register it.
let thumbnail_info = data.thumbnail_info;
if(thumbnail_info != null)
thumbnail_data.singleton().loaded_thumbnail_info([thumbnail_info], "internal");
let user_info = data.user_info;
if(user_info != null)
image_data.singleton().add_user_data(user_info);
let illust_data = data.illust_data;
if(illust_data != null)
image_data.singleton().add_illust_data(illust_data);
// To finalize, just remove preview and quick-view from the URL to turn the current
// preview into a real navigation. This is slightly different from sending "display"
// with the illust ID, since it handles navigation during quick view.
if(data.action == "finalize")
{
let args = ppixiv.helpers.args.location;
args.hash.delete("virtual");
args.hash.delete("temp-view");
ppixiv.helpers.set_page_url(args, false, "navigation");
return;
}
if(data.action == "cancel")
{
this.hide_preview_image();
return;
}
// Otherwise, we're displaying an image. quick-view displays in quick-view+virtual
// mode, display just navigates to the image normally.
console.assert(data.action == "temp-view" || data.action == "display", data.actionj);
// Show the image.
main_controller.singleton.show_media(data.media_id, {
temp_view: data.action == "temp-view",
source: "temp-view",
// When we first show a preview, add it to history. If we show another image
// or finalize the previewed image while we're showing a preview, replace the
// preview history entry.
add_to_history: !ppixiv.history.virtual,
});
}
else if(data.message == "preview-mouse-movement")
{
// Ignore this message if we're not displaying a quick view image.
if(!ppixiv.history.virtual)
return;
// The mouse moved in the tab that's sending quick view. Broadcast an event
// like pointermove.
let event = new PointerEvent("quickviewpointermove", {
movementX: data.x,
movementY: data.y,
});
window.dispatchEvent(event);
}
}
static broadcast_tab_info()
{
let screen = main_controller.singleton.displayed_screen;
let media_id = screen? screen.displayed_media_id:null;
let thumbnail_info = media_id? thumbnail_data.singleton().get_one_thumbnail_info(media_id):null;
let illust_data = media_id? image_data.singleton().get_media_info_sync(media_id):null;
let user_id = illust_data?.userId;
let user_info = user_id? image_data.singleton().get_user_info_sync(user_id):null;
let our_tab_info = {
message: "tab-info",
tab_id_tiebreaker: SendImage.tab_id_tiebreaker,
visible: !document.hidden,
title: document.title,
window_width: window.innerWidth,
window_height: window.innerHeight,
screen_x: window.screenX,
screen_y: window.screenY,
media_id: media_id,
// Include whatever we know about this image, so if we want to display this in
// another tab, we don't have to look it up again.
thumbnail_info: thumbnail_info,
illust_data: illust_data,
user_info: user_info,
};
this.send_message(our_tab_info);
// Add us to our own known_tabs.
this.known_tabs[SendImage.tab_id] = our_tab_info;
}
static send_message(data, send_to_self)
{
// Include the tab ID in all messages.
data.from = this.tab_id;
this.send_image_channel.postMessage(data);
if(send_to_self)
{
// Make a copy of data, so we don't modify the caller's copy.
data = JSON.parse(JSON.stringify(data));
// Set self to true to let us know that this is our own message.
data.self = true;
this.send_image_channel.dispatchEvent(new MessageEvent("message", { data: data }));
}
}
// If we're currently showing a preview image sent from another tab, back out to
// where we were before.
static hide_preview_image()
{
let was_in_preview = ppixiv.history.virtual;
if(!was_in_preview)
return;
ppixiv.history.back();
}
static send_mouse_movement_to_linked_tabs(x, y)
{
let tab_ids = settings.get("linked_tabs", []);
if(tab_ids.length == 0)
return;
this.pending_movement[0] += x;
this.pending_movement[1] += y;
// Limit the rate we send these, since mice with high report rates can send updates
// fast enough to saturate BroadcastChannel and cause messages to back up. Add up
// movement if we're sending too quickly and batch it into the next message.
if(this.last_movement_message_time != null && Date.now() - this.last_movement_message_time < 10)
return;
this.last_movement_message_time = Date.now();
SendImage.send_message({
message: "preview-mouse-movement",
x: this.pending_movement[0],
y: this.pending_movement[1],
to: tab_ids,
}, false);
this.pending_movement = [0, 0];
}
};
ppixiv.link_tabs_popup = class extends ppixiv.dialog_widget
{
constructor({...options})
{
super({...options, template: \`
\`});
// Close if the container is clicked, but not if something inside the container is clicked.
this.container.addEventListener("click", (e) => {
if(e.target != this.container)
return;
this.visible = false;
});
// Refresh the "unlink all tabs" button when the linked tab list changes.
settings.changes.addEventListener("linked_tabs", this.send_link_tab_message.bind(this), { signal: this.shutdown_signal.signal });
// The other tab will send these messages when the link and unlink buttons
// are clicked.
SendImage.messages.addEventListener("link-this-tab", (e) => {
let message = e.message;
let tab_ids = settings.get("linked_tabs", []);
if(tab_ids.indexOf(message.from) == -1)
tab_ids.push(message.from);
settings.set("linked_tabs", tab_ids);
this.send_link_tab_message();
}, { signal: this.shutdown_signal.signal });
SendImage.messages.addEventListener("unlink-this-tab", (e) => {
let message = e.message;
let tab_ids = settings.get("linked_tabs", []);
let idx = tab_ids.indexOf(message.from);
if(idx != -1)
tab_ids.splice(idx, 1);
settings.set("linked_tabs", tab_ids);
this.send_link_tab_message();
});
this.visible = false;
}
send_link_tab_message = () =>
{
SendImage.send_message({
message: "show-link-tab",
linked_tabs: settings.get("linked_tabs", []),
});
}
visibility_changed()
{
super.visibility_changed();
if(!this.visible)
{
SendImage.send_message({ message: "hide-link-tab" });
return;
}
helpers.interval(this.send_link_tab_message, 1000, this.visibility_abort.signal);
}
}
ppixiv.link_this_tab_popup = class extends ppixiv.dialog_widget
{
constructor({...options})
{
super({...options, template: \`
\`});
this.hide_timer = new helpers.timer(() => { this.visible = false; });
// Show ourself when we see a show-link-tab message and hide if we see a
// hide-link-tab-message.
SendImage.add_message_listener("show-link-tab", (message) => {
this.other_tab_id = message.from;
this.hide_timer.set(2000);
let linked = message.linked_tabs.indexOf(SendImage.tab_id) != -1;
this.container.querySelector(".link-this-tab").hidden = linked;
this.container.querySelector(".unlink-this-tab").hidden = !linked;
this.visible = true;
});
SendImage.add_message_listener("hide-link-tab", (message) => {
this.hide_timer.clear();
this.visible = false;
});
// When "link this tab" is clicked, send a link-this-tab message.
this.container.querySelector(".link-this-tab").addEventListener("click", (e) => {
SendImage.send_message({ message: "link-this-tab", to: [this.other_tab_id] });
// If we're linked to another tab, clear our linked tab list, to try to make
// sure we don't have weird chains of tabs linking each other.
settings.set("linked_tabs", []);
});
this.container.querySelector(".unlink-this-tab").addEventListener("click", (e) => {
SendImage.send_message({ message: "unlink-this-tab", to: [this.other_tab_id] });
});
this.visible = false;
}
visibility_changed()
{
super.visibility_changed();
this.hide_timer.clear();
// Hide if we don't see a show-link-tab message for a few seconds, as a
// safety in case the other tab dies.
if(this.visible)
this.hide_timer.set(2000);
}
}
ppixiv.send_image_popup = class extends ppixiv.dialog_widget
{
constructor({...options})
{
super({...options, template: \`
\`});
// Close if the container is clicked, but not if something inside the container is clicked.
this.container.addEventListener("click", (e) => {
if(e.target != this.container)
return;
this.visible = false;
});
SendImage.add_message_listener("take-image", (message) => {
let tab_id = message.from;
SendImage.send_image(this.media_id, [tab_id], "display");
this.visible = false;
});
this.visible = false;
}
show_for_illust(media_id)
{
this.media_id = media_id;
this.visible = true;
}
visibility_changed()
{
super.visibility_changed();
if(!this.visible)
{
SendImage.send_message({ message: "hide-send-image" });
return;
}
helpers.interval(() => {
// We should always be visible when this is called.
console.assert(this.visible);
SendImage.send_message({ message: "show-send-image" });
}, 1000, this.visibility_abort.signal);
}
}
ppixiv.send_here_popup = class extends ppixiv.dialog_widget
{
constructor({...options})
{
super({...options, template: \`
\`});
this.hide_timer = new helpers.timer(() => { this.visible = false; });
// Show ourself when we see a show-link-tab message and hide if we see a
// hide-link-tab-message.
SendImage.add_message_listener("show-send-image", (message) => {
this.other_tab_id = message.from;
this.hide_timer.set(2000);
this.visible = true;
});
SendImage.add_message_listener("hide-send-image", (message) => {
this.hide_timer.clear();
this.visible = false;
});
this.visible = false;
}
take_image = (e) =>
{
// Send take-image. The sending tab will respond with a send-image message.
SendImage.send_message({ message: "take-image", to: [this.other_tab_id] });
}
visibility_changed()
{
super.visibility_changed();
this.hide_timer.clear();
// Hide if we don't see a show-send-image message for a few seconds, as a
// safety in case the other tab dies.
if(this.visible)
{
window.addEventListener("click", this.take_image, { signal: this.visibility_abort.signal });
this.hide_timer.set(2000);
}
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/send_image.js
`;
ppixiv.resources["src/main.js"] = `"use strict";
// This handles high-level navigation and controlling the different screens.
ppixiv.main_controller = class
{
// This is called by bootstrap at startup. Just create ourself.
static launch() { new this; }
static get singleton()
{
if(main_controller._singleton == null)
throw "main_controller isn't created";
return main_controller._singleton;
}
constructor()
{
if(main_controller._singleton != null)
throw "main_controller is already created";
main_controller._singleton = this;
this.initial_setup();
}
async initial_setup()
{
if(window?.GM_info?.scriptHandler == "Greasemonkey")
{
console.info("ppixiv doesn't work with GreaseMonkey. GreaseMonkey hasn't been updated in a long time, try TamperMonkey instead.");
return;
}
// If we're not active, just see if we need to add our button, and stop without messing
// around with the page more than we need to.
if(!page_manager.singleton().active)
{
console.log("ppixiv is currently disabled");
await helpers.wait_for_content_loaded();
this.setup_disabled_ui();
return;
}
console.log("ppixiv setup");
// Install polyfills. Make sure we only do this if we're active, so we don't
// inject polyfills into Pixiv when we're not active.
install_polyfills();
// Run cleanup_environment. This will try to prevent the underlying page scripts from
// making network requests or creating elements, and apply other irreversible cleanups
// that we don't want to do before we know we're going to proceed.
helpers.cleanup_environment();
this.temporarily_hide_document();
// Wait for DOMContentLoaded to continue.
await helpers.wait_for_content_loaded();
// Continue with full initialization.
await this.setup();
}
// This is where the actual UI starts.
async setup()
{
console.log("ppixiv controller setup");
this.onkeydown = this.onkeydown.bind(this);
this.redirect_event_to_screen = this.redirect_event_to_screen.bind(this);
this.window_onclick_capture = this.window_onclick_capture.bind(this);
this.window_onpopstate = this.window_onpopstate.bind(this);
// Create the page manager.
page_manager.singleton();
// Run any one-time settings migrations.
settings.migrate();
// Migrate the translation database. We don't need to wait for this.
update_translation_storage.run();
// Set up the pointer_listener singleton.
pointer_listener.install_global_handler();
new ppixiv.global_key_listener;
// If we're running natively, set the initial URL.
await local_api.set_initial_url();
// Pixiv scripts that use meta-global-data remove the element from the page after
// it's parsed for some reason. Try to get global info from document, and if it's
// not there, re-fetch the page to get it.
if(!this.load_global_info_from_document(document))
{
if(!await this.load_global_data_async())
return;
}
// Set the .premium class on body if this is a premium account, to display features
// that only work with premium.
helpers.set_class(document.body, "premium", window.global_data.premium);
// These are used to hide UI when running native or not native.
helpers.set_class(document.body, "native", ppixiv.native);
helpers.set_class(document.body, "pixiv", !ppixiv.native);
// These are used to hide buttons that the user has disabled.
helpers.set_class(document.body, "hide-r18", !window.global_data.include_r18);
helpers.set_class(document.body, "hide-r18g", !window.global_data.include_r18g);
// See if the page has preload data. This sometimes contains illust and user info
// that the page will display, which lets us avoid making a separate API call for it.
let preload = document.querySelector("#meta-preload-data");
if(preload != null)
{
preload = JSON.parse(preload.getAttribute("content"));
for(var preload_user_id in preload.user)
image_data.singleton().add_user_data(preload.user[preload_user_id]);
for(var preload_illust_id in preload.illust)
image_data.singleton().add_illust_data(preload.illust[preload_illust_id]);
}
window.addEventListener("click", this.window_onclick_capture);
window.addEventListener("popstate", this.window_onpopstate);
window.addEventListener("keyup", this.redirect_event_to_screen, true);
window.addEventListener("keydown", this.redirect_event_to_screen, true);
window.addEventListener("keypress", this.redirect_event_to_screen, true);
window.addEventListener("keydown", this.onkeydown);
this.current_screen_name = null;
// If the URL hash doesn't start with #ppixiv, the page was loaded with the base Pixiv
// URL, and we're active by default. Add #ppixiv to the URL. If we don't do this, we'll
// still work, but none of the URLs we create will have #ppixiv, so we won't handle navigation
// directly and the page will reload on every click. Do this before we create any of our
// UI, so our links inherit the hash.
if(!ppixiv.native && !helpers.is_ppixiv_url(ppixiv.location))
{
// Don't create a new history state.
let newURL = new URL(ppixiv.location);
newURL.hash = "#ppixiv";
history.replaceState(null, "", newURL.toString());
}
// Don't restore the scroll position.
//
// If we browser back to a search page and we were scrolled ten pages down, scroll
// restoration will try to scroll down to it incrementally, causing us to load all
// data in the search from the top all the way down to where we were. This can cause
// us to spam the server with dozens of requests. This happens on F5 refresh, which
// isn't useful (if you're refreshing a search page, you want to see new results anyway),
// and recommendations pages are different every time anyway.
//
// This won't affect browser back from an image to the enclosing search.
history.scrollRestoration = "manual";
// Remove everything from the page and move it into a dummy document.
var html = document.createElement("document");
helpers.move_children(document.head, html);
helpers.move_children(document.body, html);
// Copy the location to the document copy, so the data source can tell where
// it came from.
html.location = ppixiv.location;
// Now that we've cleared the document, we can unhide it.
document.documentElement.hidden = false;
// Load image resources into blobs.
await this.load_resource_blobs();
// Add the blobs for binary resources as CSS variables.
helpers.add_style("image-styles", \`
body {
--dark-noise: url("\${resources['resources/noise.png']}");
--light-noise: url("\${resources['resources/noise-light.png']}");
}
\`);
// Add the main CSS style.
helpers.add_style("main", resources['resources/main.scss']);
// Create the page from our HTML resource.
let font_link = document.createElement("link");
font_link.href = "https://fonts.googleapis.com/icon?family=Material+Icons";
document.head.appendChild(font_link);
font_link.rel = "stylesheet";
document.body.insertAdjacentHTML("beforeend", resources['resources/main.html']);
helpers.replace_inlines(document.body);
// Create the shared title and page icon.
document.head.appendChild(document.createElement("title"));
var document_icon = document.head.appendChild(document.createElement("link"));
document_icon.setAttribute("rel", "icon");
helpers.add_clicks_to_search_history(document.body);
this.container = document.body;
SendImage.init();
// Create the popup menu handler.
this.context_menu = new main_context_menu({container: document.body});
this.link_tabs_popup = new link_tabs_popup({container: document.body});
this.link_this_tab_popup = new link_this_tab_popup({container: document.body});
this.send_here_popup = new send_here_popup({container: document.body});
this.send_image_popup = new send_image_popup({container: document.body});
// Create the main progress bar.
this.progress_bar = new progress_bar(this.container.querySelector(".loading-progress-bar"));
// Create the screens.
this.screen_search = new screen_search({ contents: this.container.querySelector(".screen-search-container") });
this.screen_illust = new screen_illust({ contents: this.container.querySelector(".screen-illust-container") });
this.screen_manga = new screen_manga({ contents: this.container.querySelector(".screen-manga-container") });
this.screens = {
search: this.screen_search,
illust: this.screen_illust,
manga: this.screen_manga,
};
// Create the data source for this page.
this.set_current_data_source("initialization");
};
window_onpopstate(e)
{
// Set the current data source and state.
this.set_current_data_source(e.navigationCause || "history");
}
async refresh_current_data_source()
{
if(this.data_source == null)
return;
// Create a new data source for the same URL, replacing the previous one.
// This returns the data source, but just call set_current_data_source so
// we load the new one.
console.log("Refreshing data source for", ppixiv.location.toString());
page_manager.singleton().create_data_source_for_url(ppixiv.location, true);
// Screens store their scroll position in args.state.scroll. On refresh, clear it
// so we scroll to the top when we refresh.
let args = helpers.args.location;
delete args.state.scroll;
helpers.set_page_url(args, false, "refresh-data-source", { send_popstate: false });
await this.set_current_data_source("refresh");
}
// Create a data source for the current URL and activate it.
//
// This is called on startup, and in onpopstate where we might be changing data sources.
async set_current_data_source(cause)
{
// Remember what we were displaying before we start changing things.
var old_screen = this.screens[this.current_screen_name];
var old_media_id = old_screen? old_screen.displayed_media_id:null;
// Get the current data source. If we've already created it, this will just return
// the same object and not create a new one.
let data_source = page_manager.singleton().create_data_source_for_url(ppixiv.location);
// If the data source supports_start_page, and a link was clicked on a page that isn't currently
// loaded, create a new data source. If we're on page 5 of bookmarks and the user clicks a link
// for page 1 (the main bookmarks navigation button) or page 10, the current data source can't
// display that since we'd need to load every page in-between to keep pages contiguous, so we
// just create a new data source.
//
// This doesn't work great for jumping to arbitrary pages (we don't handle scrolling to that page
// very well), but it at least makes rewinding to the first page work.
if(data_source == this.data_source && data_source.supports_start_page)
{
let wanted_page = this.data_source.get_start_page(helpers.args.location);
if(!data_source.can_load_page(wanted_page))
{
// This works the same as refresh_current_data_source above.
console.log("Resetting data source because it can't load the requested page", wanted_page);
data_source = page_manager.singleton().create_data_source_for_url(ppixiv.location, true);
}
}
// Figure out which screen to display.
var new_screen_name;
let args = helpers.args.location;
if(!args.hash.has("view"))
new_screen_name = data_source.default_screen;
else
new_screen_name = args.hash.get("view");
// If the data source is changing, set it up.
if(this.data_source != data_source)
{
console.log("New data source. Screen:", new_screen_name, "Cause:", cause);
if(this.data_source != null)
{
// Shut down the old data source.
this.data_source.shutdown();
// If the old data source was transient, discard it.
if(this.data_source.transient)
page_manager.singleton().discard_data_source(this.data_source);
}
// If we were showing a message for the old data source, it might be persistent,
// so clear it.
message_widget.singleton.hide();
this.data_source = data_source;
this.show_data_source_specific_elements();
this.context_menu.set_data_source(data_source);
if(this.data_source != null)
this.data_source.startup();
}
else
console.log("Same data source. Screen:", new_screen_name, "Cause:", cause);
// Update the media ID with the current manga page, if any.
let media_id = data_source.get_current_media_id();
let id = helpers.parse_media_id(media_id);
id.page = args.hash.has("page")? parseInt(args.hash.get("page"))-1:0;
media_id = helpers.encode_media_id(id);
// If we're on search, we don't care what image is current. Clear media_id so we
// tell context_menu that we're not viewing anything, so it disables bookmarking.
if(new_screen_name == "search")
media_id = null;
// Mark the current screen. Other code can watch for this to tell which view is
// active.
document.body.dataset.currentView = new_screen_name;
let new_screen = this.screens[new_screen_name];
this.context_menu.set_media_id(media_id);
this.current_screen_name = new_screen_name;
// If we're changing between screens, update the active screen.
let screen_changing = new_screen != old_screen;
// Dismiss any message when toggling between screens.
if(screen_changing)
message_widget.singleton.hide();
// Make sure we deactivate the old screen before activating the new one.
if(old_screen != null && old_screen != new_screen)
await old_screen.set_active(false, { });
if(new_screen != null)
{
// Restore state from history if this is an initial load (which may be
// restoring a tab), for browser forward/back, or if we're exiting from
// quick view (which is like browser back). This causes the pan/zoom state
// to be restored.
let restore_history = cause == "initialization" || cause == "history" || cause == "leaving-virtual";
await new_screen.set_active(true, {
data_source: data_source,
media_id: media_id,
// Let the screen know what ID we were previously viewing, if any.
old_media_id: old_media_id,
restore_history: restore_history,
});
}
}
show_data_source_specific_elements()
{
// Show UI elements with this data source in their data-datasource attribute.
var data_source_name = this.data_source.name;
for(var node of this.container.querySelectorAll(".data-source-specific[data-datasource]"))
{
var data_sources = node.dataset.datasource.split(" ");
var show_element = data_sources.indexOf(data_source_name) != -1;
node.hidden = !show_element;
}
}
// Show an illustration by ID.
//
// This actually just sets the history URL. We'll do the rest of the work in popstate.
show_media(media_id, {add_to_history=false, screen="illust", temp_view=false, source=""}={})
{
console.assert(media_id != null, "Invalid illust_id", media_id);
let args = helpers.args.location;
// Check if this is a local ID.
if(helpers.is_media_id_local(media_id))
{
// If we're told to show a folder: ID, always go to the search page, not the illust page.
if(helpers.parse_media_id(media_id).type == "folder")
screen = "search";
}
// Update the URL to display this media_id. This stays on the same data source,
// so displaying an illust won't cause a search to be made in the background or
// have other side-effects.
this._set_active_screen_in_url(args, screen);
this.data_source.set_current_media_id(media_id, args);
// Remove any leftover page from the current illust. We'll load the default.
let [illust_id, page] = helpers.media_id_to_illust_id_and_page(media_id);
if(page == null)
args.hash.delete("page");
else
args.hash.set("page", page + 1);
if(temp_view)
{
args.hash.set("virtual", "1");
args.hash.set("temp-view", "1");
}
else
{
args.hash.delete("virtual");
args.hash.delete("temp-view");
}
helpers.set_page_url(args, add_to_history, "navigation");
}
// Return the displayed screen instance.
get displayed_screen()
{
for(let screen_name in this.screens)
{
var screen = this.screens[screen_name];
if(screen.active)
return screen;
}
return null;
}
_set_active_screen_in_url(args, screen)
{
// If this is the default, just remove it.
if(screen == this.data_source.default_screen)
args.hash.delete("view");
else
args.hash.set("view", screen);
// If we're going to the search or manga page, remove the page.
// If we're going to the manga page, remove just the page.
if(screen == "search" || screen == "manga")
args.hash.delete("page");
if(screen == "search")
args.hash.delete("illust_id");
// If we're going somewhere other than illust, remove zoom state, so
// it's not still around the next time we view an image.
if(screen != "illust")
delete args.state.zoom;
}
// Navigate out.
//
// This navigates from the illust page to the manga page (for multi-page posts) or search, and
// from the manga page to search.
//
// This is similar to browser back, but allows moving up to the search even for new tabs. It
// would be better for this to integrate with browser history (just browser back if browser back
// is where we're going), but for some reason you can't view history state entries even if they're
// on the same page, so there's no way to tell where History.back() would take us.
get navigate_out_label()
{
let target = this.displayed_screen?.navigate_out_target;
switch(target)
{
case "manga": return "page list";
case "search": return "search";
default: return null;
}
}
navigate_out()
{
let new_page = this.displayed_screen?.navigate_out_target;
if(new_page == null)
return;
// If the user clicks "return to search" while on data_sources.current_illust, go somewhere
// else instead, since that viewer never has any search results.
if(new_page == "search" && this.data_source instanceof data_sources.current_illust)
{
let args = new helpers.args("/bookmark_new_illust.php#ppixiv", ppixiv.location);
helpers.set_page_url(args, true /* add_to_history */, "out");
return;
}
// Update the URL to mark whether thumbs are displayed.
let args = helpers.args.location;
this._set_active_screen_in_url(args, new_page);
helpers.set_page_url(args, true /* add_to_history */, "out");
}
// This captures clicks at the window level, allowing us to override them.
//
// When the user left clicks on a link that also goes into one of our screens,
// rather than loading a new page, we just set up a new data source, so we
// don't have to do a full navigation.
//
// This only affects left clicks (middle clicks into a new tab still behave
// normally).
window_onclick_capture(e)
{
// Only intercept regular left clicks.
if(e.button != 0 || e.metaKey || e.ctrlKey || e.altKey)
return;
if(!(e.target instanceof Element))
return;
// We're taking the place of the default behavior. If somebody called preventDefault(),
// stop.
if(e.defaultPrevented)
return;
// Look up from the target for a link.
var a = e.target.closest("A");
if(a == null)
return;
// If this isn't a #ppixiv URL, let it run normally.
var url = new URL(a.href, document.href);
if(!helpers.is_ppixiv_url(url))
return;
// Stop all handling for this link.
e.preventDefault();
e.stopImmediatePropagation();
// If this is a link to an image (usually /artworks/#), navigate to the image directly.
// This way, we actually use the URL for the illustration on this data source instead of
// switching to /artworks. This also applies to local image IDs, but not folders.
var url = new unsafeWindow.URL(url);
url = helpers.get_url_without_language(url);
let illust = this.get_illust_at_element(a);
if(illust?.media_id)
{
let media_id = illust.media_id;
let args = new helpers.args(a.href);
let screen = args.hash.has("view")? args.hash.get("view"):"illust";
this.show_media(media_id, {
screen: screen,
add_to_history: true
});
return;
}
// Navigate to the URL in-page.
helpers.set_page_url(url, true /* add to history */, "navigation");
}
async load_global_data_async()
{
console.assert(!ppixiv.native);
// Doing this sync works better, because it
console.log("Reloading page to get init data");
// /local is used as a placeholder path for the local API, and it's a 404
// on the actual page. It doesn't have global data, so load some other arbitrary
// page to get it.
let url = document.location;
if(url.pathname.startsWith('/local'))
url = new URL("/discovery", url);
// Some Pixiv pages try to force cache expiry. We really don't want that to happen
// here, since we just want to grab the page we're on quickly. Setting cache: force_cache
// tells Chrome to give us the cached page even if it's expired.
let result = await helpers.load_data_in_iframe(url.toString(), {
cache: "force-cache",
});
console.log("Finished loading init data");
if(this.load_global_info_from_document(result))
return true;
// The user is probably not logged in. If this happens on this code path, we
// can't restore the page.
console.log("Couldn't find context data. Are we logged in?");
this.show_logout_message(true);
return false;
}
// Load Pixiv's global info from doc. This can be the document, or a copy of the
// document that we fetched separately. Return true on success.
load_global_info_from_document(doc)
{
// When running locally, just load stub data, since this isn't used.
if(ppixiv.native)
{
this.init_global_data("no token", "no id", true, [], 2);
return true;
}
// Stop if we already have this.
if(window.global_data)
return true;
// This format is used on at least /new_illust.php.
let global_data = doc.querySelector("#meta-global-data");
if(global_data != null)
global_data = JSON.parse(global_data.getAttribute("content"));
// This is the global "pixiv" object, which is used on older pages.
let pixiv = helpers.get_pixiv_data(doc);
// Hack: don't use this object if we're on /history.php. It has both of these, and
// this object doesn't actually have all info, but its presence will prevent us from
// falling back and loading meta-global-data if needed.
if(document.location.pathname == "/history.php")
pixiv = null;
// Discard any of these that have no login info.
if(global_data && global_data.userData == null)
global_data = null;
if(pixiv && (pixiv.user == null || pixiv.user.id == null))
pixiv = null;
if(global_data == null && pixiv == null)
return false;
if(global_data != null)
{
this.init_global_data(global_data.token, global_data.userData.id, global_data.userData.premium,
global_data.mute, global_data.userData.xRestrict);
}
else
{
this.init_global_data(pixiv.context.token, pixiv.user.id, pixiv.user.premium,
pixiv.user.mutes, pixiv.user.explicit);
}
return true;
}
init_global_data(csrf_token, user_id, premium, mutes, content_mode)
{
var muted_tags = [];
var muted_user_ids = [];
for(var mute of mutes)
{
if(mute.type == 0)
muted_tags.push(mute.value);
else if(mute.type == 1)
muted_user_ids.push(mute.value);
}
muting.singleton.pixiv_muted_tags = muted_tags;
muting.singleton.pixiv_muted_user_ids = muted_user_ids;
window.global_data = {
// Store the token for XHR requests.
csrf_token: csrf_token,
user_id: user_id,
include_r18: content_mode >= 1,
include_r18g: content_mode >= 2,
premium: premium,
};
};
// Redirect keyboard events that didn't go into the active screen.
redirect_event_to_screen(e)
{
let screen = this.displayed_screen;
if(screen == null)
return;
// If a popup is open, leave inputs alone.
if(document.body.dataset.popupOpen)
return;
// If the keyboard input didn't go to an element inside the screen, redirect
// it to the screen's container.
var target = e.target;
// If the event is going to an element inside the screen already, just let it continue.
if(helpers.is_above(screen.container, e.target))
return;
// Clone the event and redispatch it to the screen's container.
var e2 = new e.constructor(e.type, e);
if(!screen.container.dispatchEvent(e2))
{
e.preventDefault();
e.stopImmediatePropagation();
return;
}
}
onkeydown(e)
{
// Ignore keypresses if we haven't set up the screen yet.
let screen = this.displayed_screen;
if(screen == null)
return;
// If a popup is open, leave inputs alone and don't process hotkeys.
if(document.body.dataset.popupOpen)
return;
if(e.key == "Escape")
{
e.preventDefault();
e.stopPropagation();
this.navigate_out();
return;
}
// Let the screen handle the input.
screen.handle_onkeydown(e);
}
// Return the illust_id and page or user_id of the image under element. This can
// be an image in the search screen, or a page in the manga screen.
//
// If element is an illustration and also has the user ID attached, both the user ID
// and illust ID will be returned.
get_illust_at_element(element)
{
let result = { };
if(element == null)
return result;
// Illustration search results have both the media ID and the user ID on it.
let media_element = element.closest("[data-media-id]");
if(media_element)
result.media_id = media_element.dataset.mediaId;
let user_element = element.closest("[data-user-id]");
if(user_element)
result.user_id = user_element.dataset.userId;
return result;
}
// Load binary resources into blobs, so we don't copy images into every
// place they're used.
async load_resource_blobs()
{
for(let [name, dataURL] of Object.entries(ppixiv.resources))
{
if(!dataURL.startsWith || !dataURL.startsWith("data:"))
continue;
let result = await fetch(dataURL);
let blob = await result.blob();
let blobURL = URL.createObjectURL(blob);
ppixiv.resources[name] = blobURL;
}
}
show_logout_message(force)
{
// Unless forced, don't show the message if we've already shown it recently.
// A session might last for weeks, so we don't want to force it to only be shown
// once, but we don't want to show it repeatedly.
let last_shown = window.sessionStorage.showed_logout_message || 0;
let time_since_shown = Date.now() - last_shown;
let hours_since_shown = time_since_shown / (60*60*1000);
if(!force && hours_since_shown < 6)
return;
window.sessionStorage.showed_logout_message = Date.now();
alert("Please log in to use ppixiv.");
}
temporarily_hide_document()
{
if(document.documentElement != null)
{
document.documentElement.hidden = true;
return;
}
// At this point, none of the document has loaded, and document.body and
// document.documentElement don't exist yet, so we can't hide it. However,
// we want to hide the document as soon as it's added, so we don't flash
// the original page before we have a chance to replace it. Use a mutationObserver
// to detect the document being created.
var observer = new MutationObserver((mutation_list) => {
if(document.documentElement == null)
return;
observer.disconnect();
document.documentElement.hidden = true;
});
observer.observe(document, { attributes: false, childList: true, subtree: true });
};
// When we're disabled, but available on the current page, add the button to enable us.
async setup_disabled_ui(logged_out=false)
{
// Wait for DOMContentLoaded for body.
await helpers.wait_for_content_loaded();
// On most pages, we show our button in the top corner to enable us on that page. Clicking
// it on a search page will switch to us on the same search.
var disabled_ui = helpers.create_node(resources['resources/disabled.html']);
helpers.replace_inlines(disabled_ui);
this.refresh_disabled_ui(disabled_ui);
document.body.appendChild(disabled_ui);
// Newer Pixiv pages update the URL without navigating, so refresh our button with the current
// URL. We should be able to do this in popstate, but that API has a design error: it isn't
// called on pushState, only on user navigation, so there's no way to tell when the URL changes.
// This results in the URL changing when it's clicked, but that's better than going to the wrong
// page.
disabled_ui.addEventListener("focus", (e) => { this.refresh_disabled_ui(disabled_ui); }, true);
window.addEventListener("popstate", (e) => { this.refresh_disabled_ui(disabled_ui); }, true);
if(page_manager.singleton().available_for_url(ppixiv.location))
{
// Remember that we're disabled in this tab. This way, clicking the "return
// to Pixiv" button will remember that we're disabled. We do this on page load
// rather than when the button is clicked so this works when middle-clicking
// the button to open a regular Pixiv page in a tab.
//
// Only do this if we're available and disabled, which means the user disabled us.
// If we wouldn't be available on this page at all, don't store it.
page_manager.singleton().store_ppixiv_disabled(true);
}
// If we're showing this and we know we're logged out, show a message on click.
// This doesn't work if we would be inactive anyway, since we don't know whether
// we're logged in, so the user may need to click the button twice before actually
// seeing this message.
if(logged_out)
{
disabled_ui.querySelector("a").addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
this.show_logout_message(true);
});
}
};
refresh_disabled_ui(disabled_ui)
{
// If we're on a page that we don't support, like the top page, rewrite the link to switch to
// a page we do support. Otherwise, replace the hash with #ppixiv.
console.log(ppixiv.location.toString());
if(page_manager.singleton().available_for_url(ppixiv.location))
{
let url = ppixiv.location;
url.hash = "#ppixiv";
disabled_ui.querySelector("a").href = url;
}
else
disabled_ui.querySelector("a").href = "/ranking.php?mode=daily#ppixiv";
}
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/main.js
`;
// Note that this file doesn't use strict, because JS language developers remove
// useful features without a second thought. "with" may not be used often, but
// it's an important part of the language.
(() => {
// If we're in a release build, we're inside
// (function () {
// with(this)
// {
// ...
// }
// }.exec({});
//
// The empty {} object is our environment. It can be assigned to as "this" at the
// top level of scripts, and it's included in scope using with(this) so it's searched
// as a global scope.
//
// If we're in a debug build, this script runs standalone, and we set up the environment
// here.
// Our source files are stored as text, so we can attach sourceURL to them to give them
// useful filenames. "this" is set to the ppixiv context, and we load them out here so
// we don't have many locals being exposed as globals during the eval. We also need to
// do this out here in order ot use with.
let _load_source_file = function(__pixiv, __source) {
const ppixiv = __pixiv;
with(ppixiv)
{
return eval(__source);
}
};
new class
{
constructor(env)
{
// If this is an iframe, don't do anything.
if(window.top != window.self)
return;
// Don't activate for things like sketch.pixiv.net.
if(window.location.hostname != "www.pixiv.net")
return;
console.log("ppixiv bootstrap");
// If env is the window, this script was run directly, which means this is a
// development build and we need to do some extra setup. If this is a release build,
// the environment will be set up already.
if(env === window)
this.devel_setup();
else
this.env = env;
this.launch();
}
devel_setup()
{
// In a development build, our source and binary assets are in @resources, and we need
// to pull them out into an environment manually.
let env = {};
env.resources = {};
env.resources["output/setup.js"] = JSON.parse(GM_getResourceText("output/setup.js"));
let setup = env.resources["output/setup.js"];
let source_list = setup.source_files;
// Add the file containing binary resources to the list.
source_list.unshift("output/resources.js");
for(let path of source_list)
{
// Load the source file.
let source = GM_getResourceText(path);
if(source == null)
{
// launch() will show an error for this, so don't do it here too.
continue;
}
// Add sourceURL to each file, so they show meaningful filenames in logs.
// Since we're loading the files as-is and line numbers don't change, we
// don't need a source map.
//
// This uses a path that pretends to be on the same URL as the site, which
// seems to be needed to make VS Code map the paths correctly.
source += "\n";
source += `//# sourceURL=${document.location.origin}/ppixiv/${path}\n`;
env.resources[path] = source;
}
this.env = env;
}
launch()
{
let setup = this.env.resources["output/setup.js"];
let source_list = setup.source_files;
unsafeWindow.ppixiv = this.env;
// Load each source file.
for(let path of source_list)
{
let source = this.env.resources[path];
if(!source)
{
console.error("Source file missing:", path);
continue;
}
_load_source_file(this.env, source);
}
// Create the main controller.
this.env.main_controller.launch();
}
}(this);
})();
}
}).call({});