Added image cropping for trimming borders from images.
Enable settings
Image Editing in the context menu to display the editor.
The page number is now shown over expanded manga posts while hovering over
the image, so you can collapse long posts without having to scroll back up.
\`,
},
{
version: 132,
text: \`
Improved following users, allowing changing a follow to public or private and
adding support for follow tags.
\`,
},
{
version: 129,
text: \`
Added a new way of viewing manga posts.
You can now view manga posts in search results. Click the page count in the corner of
thumbnails to show all manga pages. You can also click open_in_full
in the top menu to expand everything, or turn it on everywhere in settings.
\`,
}, {
version: 126,
text: \`
Muted tags and users can now be edited from the preferences menu.
Any number of tags can be muted. If you don't have Premium, mutes will be
saved to the browser instead of to your Pixiv account.
\`,
}, {
version: 123,
text: \`
Added support for viewing completed requests.
Disabled light mode for now. It's a pain to maintain two color schemes and everyone
is probably using dark mode anyway. If you really want it, let me know on GitHub.
\`,
},
{
version: 121,
text: \`
Added a slideshow mode. Click
wallpaper at the top.
Added an option to pan images as they're viewed.
Double-clicking images now toggles fullscreen.
The background is now fully black when viewing an image, for better contrast. Other screens are still dark grey.
Added an option to bookmark privately by default, such as when bookmarking by selecting
a bookmark tag.
Reworked the animation UI.
\`,
},
{
version: 117,
text: \`
Added Linked Tabs. Enable linked tabs in preferences to show images
on more than one monitor as they're being viewed (try it with a portrait monitor).
Showing the popup menu when Ctrl is pressed is now optional.
\`,
},
{
version: 112,
text: \`
Added Send to Tab to the context menu, which allows quickly sending an image to
another tab.
Added a More Options dropdown to the popup menu. This includes some things that
were previously only available from the hover UI. Send to Tab is also in here.
Disabled the "Similar Illustrations" lightbulb button on thumbnails. It can now be
accessed from the popup menu, along with a bunch of other ways to get image recommendations.
\`
},
{
version: 110,
text: \`
Added Quick View. This views images immediately when the mouse is pressed,
and images can be panned with the same press.
This can be enabled in preferences, and may become the default in a future release.
\`
},
{
version: 109,
boring: true,
text: \`Added a visual marker on thumbnails to show the last image you viewed.\`
},
{
version: 104,
text:
"Bookmarks can now be shuffled, to view them in random order. " +
"
" +
"Bookmarking an image now always likes it, like Pixiv's mobile app. " +
"(Having an option for this didn't seem useful.)" +
"
" +
"Added a Recent History search, to show recent search results. This can be turned " +
"off in settings."
},
{
version: 102,
boring: true,
text:
"Animations now start playing much faster."
},
{
version: 100,
text:
"Enabled auto-liking images on bookmark by default, to match the Pixiv mobile apps. " +
"If you've previously changed this in preferences, your setting should stay the same." +
"
" +
"Added a download button for the current page when viewing manga posts."
},
{
version: 97,
text:
"Holding Ctrl now displays the popup menu, to make it easier to use for people on touchpads.
" +
"Keyboard hotkeys reworked, and can now be used while hovering over search results.
" +
"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: \`
\`});
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/r137/src/whats_new.js
`;
ppixiv.resources["src/send_image.js"] = `"use strict";
// This handles sending images from one tab to another.
ppixiv.SendImage = class
{
// This is a singleton, so we never close this channel.
static send_image_channel = new BroadcastChannel("ppixiv:send-image");
// A UUID we use to identify ourself to other tabs:
static tab_id = this.create_tab_id();
static tab_id_tiebreaker = Date.now()
static create_tab_id(recreate=false)
{
// If we have a saved tab ID, use it.
if(!recreate && sessionStorage.ppixivTabId)
return sessionStorage.ppixivTabId;
// Make a new ID, and save it to the session. This helps us keep the same ID
// when we're reloaded.
sessionStorage.ppixivTabId = helpers.create_uuid();
return sessionStorage.ppixivTabId;
}
static known_tabs = {};
static initialized = false;
static init()
{
if(this.initialized)
return;
this.initialized = true;
this.pending_movement = [0, 0];
this.listeners = {};
window.addEventListener("unload", this.window_onunload);
// Let other tabs know when the info we send in tab info changes. For resize, delay this
// a bit so we don't spam broadcasts while the user is resizing the window.
window.addEventListener("resize", (e) => {
if(this.broadcast_info_after_resize_timer != -1)
clearTimeout(this.broadcast_info_after_resize_timer);
this.broadcast_info_after_resize_timer = setTimeout(this.broadcast_tab_info, 250);
});
window.addEventListener("visibilitychange", this.broadcast_tab_info);
document.addEventListener("windowtitlechanged", this.broadcast_tab_info);
// Send on window focus change, so we update things like screenX/screenY that we can't
// monitor.
window.addEventListener("focus", this.broadcast_tab_info);
window.addEventListener("blur", this.broadcast_tab_info);
window.addEventListener("popstate", this.broadcast_tab_info);
// If we gain focus while quick view is active, finalize the image. Virtual
// history isn't meant to be left enabled, since it doesn't interact with browser
// history.
window.addEventListener("focus", (e) => {
let args = ppixiv.helpers.args.location;
if(args.hash.has("temp-view"))
{
console.log("Finalizing quick view image because we gained focus");
args.hash.delete("virtual");
args.hash.delete("temp-view");
ppixiv.helpers.set_page_url(args, false, "navigation");
}
});
image_data.singleton().illust_modified_callbacks.register((media_id) => { this.broadcast_illust_changes(media_id); });
SendImage.send_image_channel.addEventListener("message", this.received_message);
this.broadcast_tab_info();
this.query_tabs();
}
static messages = new EventTarget();
static add_message_listener(message, func)
{
if(!this.listeners[message])
this.listeners[message] = [];
this.listeners[message].push(func);
}
// If we're sending an image and the page is unloaded, try to cancel it. This is
// only registered when we're sending an image.
static window_onunload = (e) =>
{
// If we were sending an image to another tab, cancel it if this tab is closed.
SendImage.send_message({
message: "send-image",
action: "cancel",
to: settings.get("linked_tabs", []),
});
// 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 broadcast_illust_changes(media_id)
{
// Don't do this if this is coming from another tab, so we don't re-broadcast data
// we just received.
if(this.handling_broadcasted_image_info)
return;
// Broadcast the new info to other tabs.
this.broadcast_image_info(media_id);
}
// Send image info to other tabs. We do this when we know about modifications to
// an image that other tabs might be displaying, such as the like count and crop
// info. This isn't done when we simply load image data from the server, so we're
// not constantly sending all search results to all tabs. We don't currently update
// thumbnail data from image data, so if a tab edits image data while it doesn't have
// thumbnail data loaded, other tabs with only thumbnail data loaded won't see it.
static broadcast_image_info(media_id)
{
// 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: "image-info",
from: SendImage.tab_id,
media_id: media_id,
illust_data: illust_data ?? thumbnail_info,
bookmark_tags: image_data.singleton().get_bookmark_details_sync(media_id),
user_info: user_info,
}, false);
}
static received_message = async(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)
await 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 == "image-info")
{
// update_media_info will trigger illust_modified_callbacks below. Make sure we don't rebroadcast
// info that we're receiving here. Note that add_illust_data can trigger loads, and we won't
// send any info for changes that happen before those complete since we have to wait
// for it to finish, but normally this receives all info for an illust anyway.
this.handling_broadcasted_image_info = true;
try {
// Another tab is broadcasting updated image info. If we have this image loaded,
// update it.
let illust_data = data.illust_data;
if(illust_data != null)
image_data.singleton().update_media_info(data.media_id, illust_data);
let bookmark_tags = data.bookmark_tags;
if(bookmark_tags != null)
image_data.singleton().update_cached_bookmark_image_tags(data.media_id, bookmark_tags);
let user_info = data.user_info;
if(user_info != null)
image_data.singleton().add_user_data(user_info);
} finally {
this.handling_broadcasted_image_info = false;
}
}
else if(data.message == "preview-mouse-movement")
{
// Ignore this message if we're not displaying a quick view image.
if(!ppixiv.history.virtual)
return;
// The mouse moved in the tab that's sending quick view. Broadcast an event
// like pointermove.
let event = new PointerEvent("quickviewpointermove", {
movementX: data.x,
movementY: data.y,
});
window.dispatchEvent(event);
}
}
static broadcast_tab_info = () =>
{
let screen = main_controller.singleton.displayed_screen;
let media_id = screen? screen.displayed_media_id:null;
let thumbnail_info = media_id? thumbnail_data.singleton().get_one_thumbnail_info(media_id):null;
let illust_data = media_id? image_data.singleton().get_media_info_sync(media_id):null;
let user_id = illust_data?.userId;
let user_info = user_id? image_data.singleton().get_user_info_sync(user_id):null;
let our_tab_info = {
message: "tab-info",
tab_id_tiebreaker: SendImage.tab_id_tiebreaker,
visible: !document.hidden,
title: document.title,
window_width: window.innerWidth,
window_height: window.innerHeight,
screen_x: window.screenX,
screen_y: window.screenY,
media_id: media_id,
// Include whatever we know about this image, so if we want to display this in
// another tab, we don't have to look it up again.
thumbnail_info: thumbnail_info,
illust_data: illust_data,
user_info: user_info,
};
this.send_message(our_tab_info);
// Add us to our own known_tabs.
this.known_tabs[SendImage.tab_id] = our_tab_info;
}
static send_message(data, send_to_self)
{
// Include the tab ID in all messages.
data.from = this.tab_id;
this.send_image_channel.postMessage(data);
if(send_to_self)
{
// Make a copy of data, so we don't modify the caller's copy.
data = JSON.parse(JSON.stringify(data));
// Set self to true to let us know that this is our own message.
data.self = true;
this.send_image_channel.dispatchEvent(new MessageEvent("message", { data: data }));
}
}
// If we're currently showing a preview image sent from another tab, back out to
// where we were before.
static hide_preview_image()
{
let was_in_preview = ppixiv.history.virtual;
if(!was_in_preview)
return;
ppixiv.history.back();
}
static send_mouse_movement_to_linked_tabs(x, y)
{
let tab_ids = settings.get("linked_tabs", []);
if(tab_ids.length == 0)
return;
this.pending_movement[0] += x;
this.pending_movement[1] += y;
// Limit the rate we send these, since mice with high report rates can send updates
// fast enough to saturate BroadcastChannel and cause messages to back up. Add up
// movement if we're sending too quickly and batch it into the next message.
if(this.last_movement_message_time != null && Date.now() - this.last_movement_message_time < 10)
return;
this.last_movement_message_time = Date.now();
SendImage.send_message({
message: "preview-mouse-movement",
x: this.pending_movement[0],
y: this.pending_movement[1],
to: tab_ids,
}, false);
this.pending_movement = [0, 0];
}
};
ppixiv.link_tabs_popup = class extends ppixiv.dialog_widget
{
constructor({...options})
{
super({...options, template: \`
\`});
// Close if the container is clicked, but not if something inside the container is clicked.
this.container.addEventListener("click", (e) => {
if(e.target != this.container)
return;
this.visible = false;
});
// Refresh the "unlink all tabs" button on other tabs when the linked tab list changes.
settings.changes.addEventListener("linked_tabs", this.send_link_tab_message, { signal: this.shutdown_signal.signal });
// The other tab will send these messages when the link and unlink buttons
// are clicked.
SendImage.messages.addEventListener("link-this-tab", (e) => {
let message = e.message;
let tab_ids = settings.get("linked_tabs", []);
if(tab_ids.indexOf(message.from) == -1)
tab_ids.push(message.from);
settings.set("linked_tabs", tab_ids);
this.send_link_tab_message();
}, { signal: this.shutdown_signal.signal });
SendImage.messages.addEventListener("unlink-this-tab", (e) => {
let message = e.message;
let tab_ids = settings.get("linked_tabs", []);
let idx = tab_ids.indexOf(message.from);
if(idx != -1)
tab_ids.splice(idx, 1);
settings.set("linked_tabs", tab_ids);
this.send_link_tab_message();
});
this.visible = false;
}
// Send show-link-tab to tell other tabs to display the "link this tab" popup.
// This includes the linked tab list, so they know whether to say "link" or "unlink".
send_link_tab_message = () =>
{
if(!this.visible)
return;
SendImage.send_message({
message: "show-link-tab",
linked_tabs: settings.get("linked_tabs", []),
});
}
visibility_changed()
{
super.visibility_changed();
if(!this.visible)
{
SendImage.send_message({ message: "hide-link-tab" });
return;
}
helpers.interval(this.send_link_tab_message, 1000, this.visibility_abort.signal);
}
}
ppixiv.link_this_tab_popup = class extends ppixiv.dialog_widget
{
constructor({...options})
{
super({...options, template: \`
\`});
this.hide_timer = new helpers.timer(() => { this.visible = false; });
// Show ourself when we see a show-link-tab message and hide if we see a
// hide-link-tab-message.
SendImage.add_message_listener("show-link-tab", (message) => {
this.other_tab_id = message.from;
this.hide_timer.set(2000);
let linked = message.linked_tabs.indexOf(SendImage.tab_id) != -1;
this.container.querySelector(".link-this-tab").hidden = linked;
this.container.querySelector(".unlink-this-tab").hidden = !linked;
this.visible = true;
});
SendImage.add_message_listener("hide-link-tab", (message) => {
this.hide_timer.clear();
this.visible = false;
});
// When "link this tab" is clicked, send a link-this-tab message.
this.container.querySelector(".link-this-tab").addEventListener("click", (e) => {
SendImage.send_message({ message: "link-this-tab", to: [this.other_tab_id] });
// If we're linked to another tab, clear our linked tab list, to try to make
// sure we don't have weird chains of tabs linking each other.
settings.set("linked_tabs", []);
});
this.container.querySelector(".unlink-this-tab").addEventListener("click", (e) => {
SendImage.send_message({ message: "unlink-this-tab", to: [this.other_tab_id] });
});
this.visible = false;
}
visibility_changed()
{
super.visibility_changed();
this.hide_timer.clear();
// Hide if we don't see a show-link-tab message for a few seconds, as a
// safety in case the other tab dies.
if(this.visible)
this.hide_timer.set(2000);
}
}
ppixiv.send_image_popup = class extends ppixiv.dialog_widget
{
constructor({...options})
{
super({...options, template: \`
\`});
// Close if the container is clicked, but not if something inside the container is clicked.
this.container.addEventListener("click", (e) => {
if(e.target != this.container)
return;
this.visible = false;
});
SendImage.add_message_listener("take-image", (message) => {
let tab_id = message.from;
SendImage.send_image(this.media_id, [tab_id], "display");
this.visible = false;
});
this.visible = false;
}
show_for_illust(media_id)
{
this.media_id = media_id;
this.visible = true;
}
visibility_changed()
{
super.visibility_changed();
if(!this.visible)
{
SendImage.send_message({ message: "hide-send-image" });
return;
}
helpers.interval(() => {
// We should always be visible when this is called.
console.assert(this.visible);
SendImage.send_message({ message: "show-send-image" });
}, 1000, this.visibility_abort.signal);
}
}
ppixiv.send_here_popup = class extends ppixiv.dialog_widget
{
constructor({...options})
{
super({...options, template: \`
\`});
this.hide_timer = new helpers.timer(() => { this.visible = false; });
// Show ourself when we see a show-link-tab message and hide if we see a
// hide-link-tab-message.
SendImage.add_message_listener("show-send-image", (message) => {
this.other_tab_id = message.from;
this.hide_timer.set(2000);
this.visible = true;
});
SendImage.add_message_listener("hide-send-image", (message) => {
this.hide_timer.clear();
this.visible = false;
});
this.visible = false;
}
take_image = (e) =>
{
// Send take-image. The sending tab will respond with a send-image message.
SendImage.send_message({ message: "take-image", to: [this.other_tab_id] });
}
visibility_changed()
{
super.visibility_changed();
this.hide_timer.clear();
// Hide if we don't see a show-send-image message for a few seconds, as a
// safety in case the other tab dies.
if(this.visible)
{
window.addEventListener("click", this.take_image, { signal: this.visibility_abort.signal });
this.hide_timer.set(2000);
}
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r137/src/send_image.js
`;
ppixiv.resources["src/main.js"] = `"use strict";
// This handles high-level navigation and controlling the different screens.
ppixiv.main_controller = class
{
// This is called by bootstrap at startup. Just create ourself.
static launch() { new this; }
static get singleton()
{
if(main_controller._singleton == null)
throw "main_controller isn't created";
return main_controller._singleton;
}
constructor()
{
if(main_controller._singleton != null)
throw "main_controller is already created";
main_controller._singleton = this;
this.initial_setup();
}
async initial_setup()
{
try {
// GM_info isn't a property on window in all script managers, so we can't check it
// safely with window.GM_info?.scriptHandler. Instead, try to check it and catch
// the exception if GM_info isn't there for some reason.
if(!ppixiv.native && GM_info?.scriptHandler == "Greasemonkey")
{
console.info("ppixiv doesn't work with GreaseMonkey. GreaseMonkey hasn't been updated in a long time, try TamperMonkey instead.");
return;
}
} catch(e) {
console.error(e);
}
// If we're not active, just see if we need to add our button, and stop without messing
// around with the page more than we need to.
if(!page_manager.singleton().active)
{
console.log("ppixiv is currently disabled");
await helpers.wait_for_content_loaded();
this.setup_disabled_ui();
return;
}
console.log("ppixiv setup");
// Install polyfills. Make sure we only do this if we're active, so we don't
// inject polyfills into Pixiv when we're not active.
install_polyfills();
// Run cleanup_environment. This will try to prevent the underlying page scripts from
// making network requests or creating elements, and apply other irreversible cleanups
// that we don't want to do before we know we're going to proceed.
helpers.cleanup_environment();
this.temporarily_hide_document();
// Wait for DOMContentLoaded to continue.
await helpers.wait_for_content_loaded();
// Continue with full initialization.
await this.setup();
}
// This is where the actual UI starts.
async setup()
{
console.log("ppixiv controller setup");
// Create the page manager.
page_manager.singleton();
// Run any one-time settings migrations.
settings.migrate();
// 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);
let refresh_focus = () => { helpers.set_class(document.body, "focused", document.hasFocus()); };
window.addEventListener("focus", refresh_focus);
window.addEventListener("blur", refresh_focus);
refresh_focus();
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";
// If we're running on Pixiv, remove Pixiv's content from the page and move it into a
// dummy document.
let html = document.createElement("document");
if(!ppixiv.native)
{
helpers.move_children(document.head, html);
helpers.move_children(document.body, html);
}
// Copy the location to the document copy, so the data source can tell where
// it came from.
html.location = ppixiv.location;
// Now that we've cleared the document, we can unhide it.
document.documentElement.hidden = false;
// Load image resources into blobs.
await this.load_resource_blobs();
// Add the blobs for binary resources as CSS variables.
helpers.add_style("image-styles", \`
body {
--dark-noise: url("\${resources['resources/noise.png']}");
}
\`);
// Add the main stylesheet.
{
let link = document.realCreateElement("link");
link.href = resources['resources/main.scss'];
link.rel = "stylesheet";
document.querySelector("head").appendChild(link);
}
// If enabled, cache local info which tells us what we have access to.
await local_api.load_local_info();
// If login is required to do anything, no API calls will succeed. Stop now and
// just redirect to login. This is only for the local API.
if(local_api.local_info.enabled && local_api.local_info.login_required)
{
local_api.redirect_to_login();
return;
}
// Create the page from our HTML resource.
let font_link = document.createElement("link");
font_link.href = "https://fonts.googleapis.com/icon?family=Material+Icons";
document.head.appendChild(font_link);
font_link.rel = "stylesheet";
document.body.insertAdjacentHTML("beforeend", resources['resources/main.html']);
helpers.replace_inlines(document.body);
// Create the shared title and page icon.
document.head.appendChild(document.createElement("title"));
var document_icon = document.head.appendChild(document.createElement("link"));
document_icon.setAttribute("rel", "icon");
helpers.add_clicks_to_search_history(document.body);
this.container = document.body;
SendImage.init();
// Create the popup menu handler.
this.context_menu = new main_context_menu({container: document.body});
this.link_tabs_popup = new link_tabs_popup({container: document.body});
this.link_this_tab_popup = new link_this_tab_popup({container: document.body});
this.send_here_popup = new send_here_popup({container: document.body});
this.send_image_popup = new send_image_popup({container: document.body});
// Create the main progress bar.
this.progress_bar = new progress_bar(this.container.querySelector(".loading-progress-bar"));
// Create the screens.
this.screen_search = new screen_search({ contents: this.container.querySelector(".screen-search-container") });
this.screen_illust = new screen_illust({ contents: this.container.querySelector(".screen-illust-container") });
this.screens = {
search: this.screen_search,
illust: this.screen_illust,
};
// Create the data source for this page.
this.set_current_data_source("initialization");
};
window_onpopstate = (e) =>
{
// Set the current data source and state.
this.set_current_data_source(e.navigationCause || "history");
}
async refresh_current_data_source()
{
if(this.data_source == null)
return;
// Create a new data source for the same URL, replacing the previous one.
// This returns the data source, but just call set_current_data_source so
// we load the new one.
console.log("Refreshing data source for", ppixiv.location.toString());
page_manager.singleton().create_data_source_for_url(ppixiv.location, true);
// Screens store their scroll position in args.state.scroll. On refresh, clear it
// so we scroll to the top when we refresh.
let args = helpers.args.location;
delete args.state.scroll;
helpers.set_page_url(args, false, "refresh-data-source", { send_popstate: false });
await this.set_current_data_source("refresh");
}
// Create a data source for the current URL and activate it.
//
// This is called on startup, and in onpopstate where we might be changing data sources.
async set_current_data_source(cause)
{
// Remember what we were displaying before we start changing things.
var old_screen = this.screens[this.current_screen_name];
var old_media_id = old_screen? old_screen.displayed_media_id:null;
// Get the current data source. If we've already created it, this will just return
// the same object and not create a new one.
let data_source = page_manager.singleton().create_data_source_for_url(ppixiv.location);
// If the data source supports_start_page, and a link was clicked on a page that isn't currently
// loaded, create a new data source. If we're on page 5 of bookmarks and the user clicks a link
// for page 1 (the main bookmarks navigation button) or page 10, the current data source can't
// display that since we'd need to load every page in-between to keep pages contiguous, so we
// just create a new data source.
//
// This doesn't work great for jumping to arbitrary pages (we don't handle scrolling to that page
// very well), but it at least makes rewinding to the first page work.
if(data_source == this.data_source && data_source.supports_start_page)
{
let wanted_page = this.data_source.get_start_page(helpers.args.location);
if(!data_source.can_load_page(wanted_page))
{
// This works the same as refresh_current_data_source above.
console.log("Resetting data source because it can't load the requested page", wanted_page);
data_source = page_manager.singleton().create_data_source_for_url(ppixiv.location, true);
}
}
// Figure out which screen to display.
var new_screen_name;
let args = helpers.args.location;
if(!args.hash.has("view"))
new_screen_name = data_source.default_screen;
else
new_screen_name = args.hash.get("view");
// If the data source is changing, set it up.
if(this.data_source != data_source)
{
console.log("New data source. Screen:", new_screen_name, "Cause:", cause);
if(this.data_source != null)
{
// Shut down the old data source.
this.data_source.shutdown();
// If the old data source was transient, discard it.
if(this.data_source.transient)
page_manager.singleton().discard_data_source(this.data_source);
}
// If we were showing a message for the old data source, it might be persistent,
// so clear it.
message_widget.singleton.hide();
this.data_source = data_source;
this.show_data_source_specific_elements();
this.context_menu.set_data_source(data_source);
if(this.data_source != null)
this.data_source.startup();
}
else
console.log("Same data source. Screen:", new_screen_name, "Cause:", cause);
// Update the media ID with the current manga page, if any.
let media_id = data_source.get_current_media_id();
let id = helpers.parse_media_id(media_id);
id.page = args.hash.has("page")? parseInt(args.hash.get("page"))-1:0;
media_id = helpers.encode_media_id(id);
// If we're on search, we don't care what image is current. Clear media_id so we
// tell context_menu that we're not viewing anything, so it disables bookmarking.
if(new_screen_name == "search")
media_id = null;
// Mark the current screen. Other code can watch for this to tell which view is
// active.
document.body.dataset.currentView = new_screen_name;
let new_screen = this.screens[new_screen_name];
this.context_menu.set_media_id(media_id);
this.current_screen_name = new_screen_name;
// If we're changing between screens, update the active screen.
let screen_changing = new_screen != old_screen;
// Dismiss any message when toggling between screens.
if(screen_changing)
message_widget.singleton.hide();
// Make sure we deactivate the old screen before activating the new one.
if(old_screen != null && old_screen != new_screen)
await old_screen.set_active(false, { });
if(old_screen != new_screen)
{
let e = new Event("screenchanged");
e.newScreen = new_screen_name;
window.dispatchEvent(e);
}
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.
get_media_url(media_id, {screen="illust", temp_view=false}={})
{
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");
}
return args;
}
show_media(media_id, {add_to_history=false, source="", ...options}={})
{
let args = this.get_media_url(media_id, options);
helpers.set_page_url(args, add_to_history, "navigation");
}
// Return the displayed screen instance.
get displayed_screen()
{
for(let screen_name in this.screens)
{
var screen = this.screens[screen_name];
if(screen.active)
return screen;
}
return null;
}
_set_active_screen_in_url(args, screen)
{
// If this is the default, just remove it.
if(screen == this.data_source.default_screen)
args.hash.delete("view");
else
args.hash.set("view", screen);
// If we're going to the search screen, remove the page and illust ID.
if(screen == "search")
{
args.hash.delete("page");
args.hash.delete("illust_id");
}
// If we're going somewhere other than illust, remove zoom state, so
// it's not still around the next time we view an image.
if(screen != "illust")
delete args.state.zoom;
}
get navigate_out_enabled()
{
if(this.current_screen_name != "illust" || this.data_source == null)
return false;
let media_id = this.data_source.get_current_media_id();
if(media_id == null)
return false;
let info = thumbnail_data.singleton().get_illust_data_sync(media_id);
if(info == null)
return false;
return info.pageCount > 1;
}
navigate_out()
{
if(!this.navigate_out_enabled)
return;
let media_id = this.data_source.get_current_media_id();
if(media_id == null)
return;
let [illust_id, illust_page] = helpers.media_id_to_illust_id_and_page(media_id);
let args = new helpers.args(\`/artworks/\${illust_id}#ppixiv?manga=1\`);
helpers.set_page_url(args, true /* add_to_history */, "out");
}
// This captures clicks at the window level, allowing us to override them.
//
// When the user left clicks on a link that also goes into one of our screens,
// rather than loading a new page, we just set up a new data source, so we
// don't have to do a full navigation.
//
// This only affects left clicks (middle clicks into a new tab still behave
// normally).
window_onclick_capture = (e) =>
{
// Only intercept regular left clicks.
if(e.button != 0 || e.metaKey || e.ctrlKey || e.altKey)
return;
if(!(e.target instanceof Element))
return;
// We're taking the place of the default behavior. If somebody called preventDefault(),
// stop.
if(e.defaultPrevented)
return;
// Look up from the target for a link.
var a = e.target.closest("A");
if(a == null || !a.hasAttribute("href"))
return;
// If this isn't a #ppixiv URL, let it run normally.
let url = new unsafeWindow.URL(a.href, document.href);
if(!helpers.is_ppixiv_url(url))
return;
// Stop all handling for this link.
e.preventDefault();
e.stopImmediatePropagation();
// If this is a link to an image (usually /artworks/#), navigate to the image directly.
// This way, we actually use the URL for the illustration on this data source instead of
// switching to /artworks. This also applies to local image IDs, but not folders.
url = helpers.get_url_without_language(url);
let illust = this.get_illust_at_element(a);
if(illust?.media_id)
{
let media_id = illust.media_id;
let args = new helpers.args(a.href);
let screen = args.hash.has("view")? args.hash.get("view"):"illust";
this.show_media(media_id, {
screen: screen,
add_to_history: true
});
return;
}
// Navigate to the URL in-page.
helpers.set_page_url(url, true /* add to history */, "navigation");
}
async load_global_data_async()
{
console.assert(!ppixiv.native);
// Doing this sync works better, because it
console.log("Reloading page to get init data");
// /local is used as a placeholder path for the local API, and it's a 404
// on the actual page. It doesn't have global data, so load some other arbitrary
// page to get it.
let url = document.location;
if(url.pathname.startsWith('/local'))
url = new URL("/discovery", url);
// Some Pixiv pages try to force cache expiry. We really don't want that to happen
// here, since we just want to grab the page we're on quickly. Setting cache: force_cache
// tells Chrome to give us the cached page even if it's expired.
let result = await helpers.load_data_in_iframe(url.toString(), {
cache: "force-cache",
});
console.log("Finished loading init data");
if(this.load_global_info_from_document(result))
return true;
// The user is probably not logged in. If this happens on this code path, we
// can't restore the page.
console.log("Couldn't find context data. Are we logged in?");
this.show_logout_message(true);
return false;
}
// Load Pixiv's global info from doc. This can be the document, or a copy of the
// document that we fetched separately. Return true on success.
load_global_info_from_document(doc)
{
// When running locally, just load stub data, since this isn't used.
if(ppixiv.native)
{
this.init_global_data("no token", "no id", true, [], 2);
return true;
}
// Stop if we already have this.
if(window.global_data)
return true;
// This format is used on at least /new_illust.php.
let global_data = doc.querySelector("#meta-global-data");
if(global_data != null)
global_data = JSON.parse(global_data.getAttribute("content"));
// This is the global "pixiv" object, which is used on older pages.
let pixiv = helpers.get_pixiv_data(doc);
// Hack: don't use this object if we're on /history.php. It has both of these, and
// this object doesn't actually have all info, but its presence will prevent us from
// falling back and loading meta-global-data if needed.
if(document.location.pathname == "/history.php")
pixiv = null;
// Discard any of these that have no login info.
if(global_data && global_data.userData == null)
global_data = null;
if(pixiv && (pixiv.user == null || pixiv.user.id == null))
pixiv = null;
if(global_data == null && pixiv == null)
return false;
if(global_data != null)
{
this.init_global_data(global_data.token, global_data.userData.id, global_data.userData.premium,
global_data.mute, global_data.userData.xRestrict);
}
else
{
this.init_global_data(pixiv.context.token, pixiv.user.id, pixiv.user.premium,
pixiv.user.mutes, pixiv.user.explicit);
}
return true;
}
init_global_data(csrf_token, user_id, premium, mutes, content_mode)
{
var muted_tags = [];
var muted_user_ids = [];
for(var mute of mutes)
{
if(mute.type == 0)
muted_tags.push(mute.value);
else if(mute.type == 1)
muted_user_ids.push(mute.value);
}
muting.singleton.pixiv_muted_tags = muted_tags;
muting.singleton.pixiv_muted_user_ids = muted_user_ids;
window.global_data = {
// Store the token for XHR requests.
csrf_token: csrf_token,
user_id: user_id,
include_r18: content_mode >= 1,
include_r18g: content_mode >= 2,
premium: premium,
};
};
// Redirect keyboard events that didn't go into the active screen.
redirect_event_to_screen = (e) =>
{
let screen = this.displayed_screen;
if(screen == null)
return;
// If a popup is open, leave inputs alone.
if(document.body.dataset.popupOpen)
return;
// If the keyboard input didn't go to an element inside the screen, redirect
// it to the screen's container.
var target = e.target;
// If the event is going to an element inside the screen already, just let it continue.
if(helpers.is_above(screen.container, e.target))
return;
// Clone the event and redispatch it to the screen's container.
var e2 = new e.constructor(e.type, e);
if(!screen.container.dispatchEvent(e2))
{
e.preventDefault();
e.stopImmediatePropagation();
return;
}
}
onkeydown = (e) =>
{
// Ignore keypresses if we haven't set up the screen yet.
let screen = this.displayed_screen;
if(screen == null)
return;
// If a popup is open, leave inputs alone and don't process hotkeys.
if(document.body.dataset.popupOpen)
return;
if(e.key == "Escape")
{
e.preventDefault();
e.stopPropagation();
this.navigate_out();
return;
}
// Let the screen handle the input.
screen.handle_onkeydown(e);
}
// Return the illust_id and page or user_id of the image under element. This can
// be an image in the search screen, or a page in the manga screen.
//
// If element is an illustration and also has the user ID attached, both the user ID
// and illust ID will be returned.
get_illust_at_element(element)
{
let result = { };
if(element == null)
return result;
// Illustration search results have both the media ID and the user ID on it.
let media_element = element.closest("[data-media-id]");
if(media_element)
result.media_id = media_element.dataset.mediaId;
let user_element = element.closest("[data-user-id]");
if(user_element)
result.user_id = user_element.dataset.userId;
return result;
}
// Load binary resources into blobs, so we don't copy images into every
// place they're used.
async load_resource_blobs()
{
for(let [name, dataURL] of Object.entries(ppixiv.resources))
{
if(!dataURL.startsWith || !dataURL.startsWith("data:"))
continue;
let result = await helpers.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/r137/src/main.js
`;
ppixiv.resources["src/slideshow.js"] = `// This handles the nitty slideshow logic for on_click_viewer.
ppixiv.slideshow = class
{
constructor({
// The size of the image being displayed:
width, height,
// The size of the window:
container_width, container_height,
// The minimum zoom level to allow:
minimum_zoom,
// If true, we're being used for slideshow mode, otherwise auto-pan mode.
slideshow_enabled,
// The slideshow is normally clamped to the window. This can be disabled by the
// editor.
clamp_to_window=true,
})
{
this.width = width;
this.height = height;
this.container_width = container_width;
this.container_height = container_height;
this.minimum_zoom = minimum_zoom;
this.slideshow_enabled = slideshow_enabled;
this.clamp_to_window = clamp_to_window;
}
// Return some parameters that are used by linear animation getters below.
_get_parameters()
{
// The target duration of the animation:
let pan_duration = this.slideshow_enabled?
ppixiv.settings.get("slideshow_duration"):
ppixiv.settings.get("auto_pan_duration");
let ease;
if(this.slideshow_enabled)
{
// In slideshow mode, we always fade through black, so we don't need any easing on the
// transition.
ease = "linear";
}
else
{
// There's no fading in auto-pan mode. Use an ease-out transition, so we start
// quickly and decelerate at the end. We're jumping from another image anyway
// so an ease-in doesn't seem needed.
//
// A standard ease-out is (0, 0, 0.58, 1). We can change the strength of the effect
// by changing the third value, becoming completely linear when it reaches 1. Reduce
// the ease-out effect as the duration gets longer, since longer animations don't need
// the ease-out as much (they're already slow), so we have more even motion.
let factor = helpers.scale_clamp(pan_duration, 5, 15, 0.58, 1);
ease = \`cubic-bezier(0.0, 0.0, \${factor}, 1.0)\`;
}
// Max speed sets how fast the image is allowed to move. If it's 0.5, the image shouldn't
// scroll more half a screen per second, and the duration will be increased if needed to slow
// it down. This keeps the animation from being too fast for very tall and wide images.
//
// Scale the max speed based on the duration. With a 5-second duration, allow the image
// to move half a screen per second. With a 15-second duration, slow it down to no more
// than a quarter screen per second.
let max_speed = helpers.scale(pan_duration, 5, 15, 0.5, 0.25);
max_speed = helpers.clamp(max_speed, 0.25, 0.5);
// Choose a fade duration. This needs to be quicker if the slideshow is very brief.
let fade_in = this.slideshow_enabled? Math.min(pan_duration * 0.1, 2.5):0;
let fade_out = this.slideshow_enabled? Math.min(pan_duration * 0.1, 2.5):0;
return { ease, pan_duration, max_speed, fade_in, fade_out };
}
// Create the default animation.
get_default_animation()
{
let animation = this.get_default_pan();
animation = this.prepare_animation(animation);
// If the animation didn't go anywhere, the visible area's aspect ratio very closely
// matches the screen's, so there's nowhere to pan. Use a pull-in animation instead.
// We don't currently use this in pan mode, because zooming the image when in pan mode
// and controlling multiple tabs can be annoying.
if(animation.total_travel > 0.05 || !this.slideshow_enabled)
return animation;
console.log(\`Slideshow: pan animation had nowhere to move, using a pull-in instead (total_travel \${animation.total_travel})\`);
return this.prepare_animation(this.get_pull_in());
}
// Load a saved animation created with PanEditor.
get_animation_from_pan(pan)
{
let { ease, pan_duration, max_speed, fade_in, fade_out } = this._get_parameters();
let animation = {
fade_in, fade_out,
pan: [{
x: pan.x1, y: pan.y1, zoom: pan.start_zoom ?? 1,
anchor_x: pan.anchor?.left ?? 0.5,
anchor_y: pan.anchor?.top ?? 0.5,
max_speed: true,
speed: max_speed,
duration: pan_duration,
ease,
}, {
x: pan.x2, y: pan.y2, zoom: pan.end_zoom ?? 1,
anchor_x: pan.anchor?.right ?? 0.5,
anchor_y: pan.anchor?.bottom ?? 0.5,
}],
};
return this.prepare_animation(animation);
}
// This is like the thumbnail animation, which gives a reasonable default for both landscape
// and portrait animations.
get_default_pan()
{
let { ease, pan_duration, max_speed, fade_in, fade_out } = this._get_parameters();
return {
fade_in, fade_out,
pan: [{
x: 0, y: 0, zoom: 1,
max_speed: true,
speed: max_speed,
duration: pan_duration,
ease,
}, {
x: 1, y: 1, zoom: 1,
}],
};
}
// Return a basic pull-in animation.
get_pull_in()
{
let { pan_duration, ease, fade_in, fade_out } = this._get_parameters();
// This zooms from "contain" to a slight zoom over "cover".
return {
fade_in, fade_out,
pan: [{
x: 0.5, y: 0.0, zoom: 0,
duration: pan_duration,
ease,
}, {
x: 0.5, y: 0.0, zoom: 1.2,
}],
};
}
// Prepare an animation. This figures out the actual translate and scale for each
// keyframe, and the total duration. The results depend on the image and window
// size.
prepare_animation(animation)
{
// Make a deep copy before modifying it.
animation = JSON.parse(JSON.stringify(animation));
let screen_width = this.container_width;
let screen_height = this.container_height;
animation.default_width = this.width;
animation.default_height = this.height;
// Don't let the zoom go below the original 1:1 size. This allows panning to 1:1
// by setting zoom to 0. There's no inherent max zoom.
let minimum_zoom = this.minimum_zoom;
let maximum_zoom = 999;
// Calculate the scale and translate for each point.
for(let point of animation.pan)
{
let zoom = helpers.clamp(point.zoom, minimum_zoom, maximum_zoom);
// The screen size the image will have:
let zoomed_width = animation.default_width * zoom;
let zoomed_height = animation.default_height * zoom;
// Initially, the image will be aligned to the top-left of the screen. Shift right and
// down to align the anchor the origin. This is usually the center of the image.
let { anchor_x=0.5, anchor_y=0.5 } = point;
let move_x = screen_width * anchor_x;
let move_y = screen_height * anchor_y;
// Then shift up and left to center the point:
move_x -= point.x*zoomed_width;
move_y -= point.y*zoomed_height;
if(this.clamp_to_window)
{
// Clamp the translation to keep the image in the window. This is inverted, since
// move_x and move_y are transitions and not the image position.
let max_x = zoomed_width - screen_width, max_y = zoomed_height - screen_height;
move_x = helpers.clamp(move_x, 0, -max_x);
move_y = helpers.clamp(move_y, 0, -max_y);
// If the image isn't filling the screen on either axis, center it. This only applies at
// keyframes (we won't always be centered while animating).
if(zoomed_width < screen_width)
move_x = (screen_width - zoomed_width) / 2;
if(zoomed_height < screen_height)
move_y = (screen_height - zoomed_height) / 2;
}
point.computed_zoom = zoom;
point.computed_tx = move_x;
point.computed_ty = move_y;
}
// Calculate the duration for keyframes that specify a speed.
//
// If max_speed is true, speed is a cap. We'll move at the specified duration or
// the duration based on speed, whichever is longer.
for(let idx = 0; idx < animation.pan.length - 1; ++idx)
{
let p0 = animation.pan[idx+0];
let p1 = animation.pan[idx+1];
if(p0.speed == null)
continue;
// speed is relative to the screen size, so it's not tied too tightly to the resolution
// of the window. The "size" of the window depends on which way we're moving: if we're moving
// horizontally we only care about the horizontal size, and if we're moving diagonally, weight
// the two. This way, the speed is relative to the screen size in the direction we're moving.
// If it's 0.5 and we're only moving horizontally, we'll move half a screen width per second.
let distance_x = Math.abs(p0.computed_tx - p1.computed_tx);
let distance_y = Math.abs(p0.computed_ty - p1.computed_ty);
if(distance_x == 0 && distance_y == 0)
{
// We're not moving at all. If the animation is based on speed, just set a small duration
// to avoid division by zero.
p0.actual_speed = 0;
if(p0.duration == null)
p0.duration = 0.1;
continue;
}
let distance_ratio = distance_y / (distance_x + distance_y); // 0 = horizontal only, 1 = vertical only
let screen_size = (screen_height * distance_ratio) + (screen_width * (1-distance_ratio));
// The screen distance we're moving:
let distance_in_pixels = helpers.distance([p0.computed_tx, p0.computed_ty], [p1.computed_tx, p1.computed_ty]);
// pixels_per_second is the speed we'll move at the given speed. Note that this ignores
// easing, and we'll actually move faster or slower than this during the transition.
let speed = Math.max(p0.speed, 0.01);
let pixels_per_second = speed * screen_size;
let duration = distance_in_pixels / pixels_per_second;
if(p0.max_speed)
p0.duration = Math.max(p0.duration, duration);
else
p0.duration = duration;
// Reverse it to get the actual speed we ended up with.
let actual_pixels_per_second = distance_in_pixels / p0.duration;
p0.actual_speed = actual_pixels_per_second / screen_size;
}
// Calculate the total duration. The last point doesn't have a duration.
let total_time = 0;
for(let point of animation.pan.slice(0, animation.pan.length-1))
total_time += point.duration;
animation.total_time = Math.max(total_time, 0.01);
// For convenience, calculate total distance the animation travelled.
animation.total_travel = 0;
for(let point of animation.pan)
{
if(point.actual_speed == null)
continue;
animation.total_travel += point.actual_speed * point.duration;
}
return animation;
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r137/src/slideshow.js
`;
ppixiv.resources["src/extra_image_data.js"] = `"use strict";
// This database is used to store extra metadata for Pixiv images. It's similar
// to the metadata files in the local database.
//
// Data is stored by media ID, with a separate record for each manga page. We
// have an index on the illust ID, so we can fetch all pages for an illust ID quickly.
ppixiv.extra_image_data = class
{
// Return the singleton, creating it if needed.
static get get()
{
if(extra_image_data._singleton == null)
extra_image_data._singleton = new extra_image_data();
return extra_image_data._singleton;
};
constructor()
{
// This is only needed for storing data for Pixiv images. We don't need it if
// we're native.
if(ppixiv.native)
return;
this.db = new key_storage("ppixiv-image-data", { db_upgrade: this.db_upgrade });
}
db_upgrade = (e) => {
// Create our object store with an index on illust_id.
let db = e.target.result;
let store = db.createObjectStore("ppixiv-image-data");
store.createIndex("illust_id", "illust_id");
store.createIndex("edited_at", "edited_at");
}
async save_illust(media_id, data)
{
if(this.db == null)
return;
await this.db.set(media_id, data);
}
async delete_illust(media_id)
{
if(this.db == null)
return;
await this.db.delete(media_id);
}
// Return extra data for the given media IDs if we have it, as a media_id: data dictionary.
async load_illust_data(media_ids)
{
if(this.db == null)
return {};
return await this.db.db_op(async (db) => {
let store = this.db.get_store(db);
// Load data in bulk.
let promises = {};
for(let media_id of media_ids)
promises[media_id] = key_storage.async_store_get(store, media_id);
return await helpers.await_map(promises);
});
}
// Return data for all pages of illust_id.
async load_all_pages_for_illust(illust_id)
{
if(this.db == null)
return {};
return await this.db.db_op(async (db) => {
let store = this.db.get_store(db);
let index = store.index("illust_id");
let query = IDBKeyRange.only(illust_id);
let cursor = index.openCursor(query);
let results = {};
for await (let entry of cursor)
{
let media_id = entry.primaryKey;
results[media_id] = entry.value;
}
return results;
});
}
// Batch load a list of illust_ids. The results are returned mapped by illust_id.
async batch_load_all_pages_for_illust(illust_ids)
{
if(this.db == null)
return {};
return await this.db.db_op(async (db) => {
let store = this.db.get_store(db);
let index = store.index("illust_id");
let promises = {};
for(let illust_id of illust_ids)
{
let query = IDBKeyRange.only(illust_id);
let cursor = index.openCursor(query);
promises[illust_id] = (async() => {
let results = {};
for await (let entry of cursor)
{
let media_id = entry.primaryKey;
results[media_id] = entry.value;
}
return results;
})();
}
return await helpers.await_map(promises);
});
}
// Return the media ID of all illust IDs.
//
// Note that we don't use an async iterator for this, since it might not be closed
// until it's GC'd and we need to close the database consistently.
async get_all_edited_images({sort="time"}={})
{
console.assert(sort == "time" || sort == "id");
if(this.db == null)
return [];
return await this.db.db_op(async (db) => {
let store = this.db.get_store(db);
let index = sort == "time"? store.index("edited_at"):store;
let cursor = index.openKeyCursor(null, sort == "time"? "prev":"next"); // descending for time
let results = [];
for await (let entry of cursor)
{
let media_id = entry.primaryKey;
results.push(media_id);
}
return results;
});
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r137/src/extra_image_data.js
`;
ppixiv.resources["setup.js"] = {
"source_files": [
"src/polyfills.js",
"src/actions.js",
"src/crc32.js",
"src/helpers.js",
"src/settings.js",
"src/fix_chrome_clicks.js",
"src/widgets.js",
"src/local_api.js",
"src/local_widgets.js",
"src/muting.js",
"src/editing.js",
"src/editing_crop.js",
"src/editing_inpaint.js",
"src/editing_pan.js",
"src/menu_option.js",
"src/main_context_menu.js",
"src/create_zip.js",
"src/data_sources.js",
"src/encode_mkv.js",
"src/hide_mouse_cursor_on_idle.js",
"src/image_data.js",
"src/on_click_viewer.js",
"src/progress_bar.js",
"src/seek_bar.js",
"src/struct.js",
"src/ugoira_downloader_mjpeg.js",
"src/viewer.js",
"src/viewer_images.js",
"src/viewer_muted.js",
"src/viewer_ugoira.js",
"src/viewer_video.js",
"src/zip_image_player.js",
"src/screen.js",
"src/screen_illust.js",
"src/screen_search.js",
"src/image_ui.js",
"src/tag_search_dropdown_widget.js",
"src/recently_seen_illusts.js",
"src/tag_translations.js",
"src/thumbnail_data.js",
"src/page_manager.js",
"src/image_preloading.js",
"src/whats_new.js",
"src/send_image.js",
"src/main.js",
"src/slideshow.js",
"src/extra_image_data.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.
// 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;
// Work around quoid/userscripts not defining unsafeWindow.
try {
unsafeWindow.x;
} catch(e) {
window.unsafeWindow = window;
}
// Make sure that we're not loaded more than once. This can happen if we're installed in
// multiple script managers, or if the release and debug versions are enabled simultaneously.
if(unsafeWindow.loaded_ppixiv)
{
console.error("ppixiv has been loaded twice. Is it loaded in multiple script managers?");
return;
}
unsafeWindow.loaded_ppixiv = true;
console.log(`ppixiv r${env.version} bootstrap`);
let setup = env.resources["setup.js"];
let source_list = setup.source_files;
// This is just for development, so we can access ourself in the console.
unsafeWindow.ppixiv = env;
env.native = false;
env.ios = navigator.platform.indexOf('iPhone') != -1 || navigator.platform.indexOf('iPad') != -1;
// Load each source file.
for(let path of source_list)
{
let source = env.resources[path];
if(!source)
{
console.error("Source file missing:", path);
continue;
}
_load_source_file(env, source);
}
// Load the stylesheet into a URL. This is just so we behave the same
// as bootstrap_native.
for(let [name, data] of Object.entries(env.resources))
{
if(!name.endsWith(".scss"))
continue;
let blob = new Blob([data]);
let blobURL = URL.createObjectURL(blob);
env.resources[name] = blobURL;
}
// Create the main controller.
env.main_controller.launch();
}
}(this);
})();
}
}).call({});