This page can be bookmarked. or added to the home screen on iOS.
The bookmark will begin a slideshow with the current search.
\`});
this.url = helpers.args.location;
}
visibility_changed()
{
super.visibility_changed();
if(!this.visible)
{
// If the URL is still pointing at the slideshow, back out to restore the original
// URL. This is needed if we're exiting from the user clicking out of the dialog,
// but don't do it if we're exiting from browser back.
if(helpers.args.location.toString() == this.url.toString())
history.back();
}
}
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r151/src/screen_search.js
`;
ppixiv.resources["src/search_view.js"] = `// JavaScript objects are ordered, but for some reason there's no way to actually manipulate
// the order, such as adding to the beginning. We have to make a copy of the object, add
// our new entry, then add everything else.
function add_to_beginning(object, key, value)
{
let result = {};
result[key] = value;
for(let [old_key, old_value] of Object.entries(object))
{
if(old_key != key)
result[old_key] = old_value;
}
return result;
}
// Similar to add_to_beginning, this adds at the end. Note that while add_to_beginning returns a
// new object, this edits the object in-place. We need to be careful with this, but it avoids making
// a copy of the thumb dictionary every time we append to the end. To make it clearer that this
// differs from add_to_beginning, this doesn't return the object.
function add_to_end(object, key, value)
{
// Remove the key if it exists, so it's moved to the end.
delete object[key];
object[key] = value;
}
// The main thumbnail grid view.
ppixiv.search_view = class extends ppixiv.widget
{
constructor({
// This is called if we change the start page in the URL.
onstartpagechanged,
...options})
{
super({...options,template: \`
Thumbnail panning now stops after a while if there's no mouse movement,
so it doesn't keep going forever.
\`,
},
{
version: 139,
text: \`
Added a panning/slideshow editor, to edit how an image will pan and zoom during
slideshows. Right-click and enable
\${ helpers.create_icon("settings") } \${ helpers.create_icon("brush") } Image Editing, then
\${ helpers.create_icon("wallpaper") } Edit Panning while viewing an image.
Added a button to \${ helpers.create_icon("restart_alt") }
Refresh the search from the current page. The \${ helpers.create_icon("refresh") }
Refresh button now always restarts from the beginning.
\`,
},
{
version: 133,
text: \`
Pressing Ctrl-P now toggles image panning.
Added image cropping for trimming borders from images.
Enable \${ helpers.create_icon("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 \${ helpers.create_icon("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 \${ helpers.create_icon("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, classes: "whats-new-box", template: \`
\`});
entry.querySelector(".rev").innerText = "r" + update.version;
entry.querySelector(".text").innerHTML = update.text;
items_box.appendChild(entry);
}
}
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r151/src/whats_new.js
`;
ppixiv.resources["src/send_image.js"] = `"use strict";
// This handles sending images from one tab to another.
ppixiv.SendImage = class
{
constructor()
{
// This is a singleton, so we never close this channel.
this.send_image_channel = new ppixiv.LocalBroadcastChannel("ppixiv:send-image");
// A UUID we use to identify ourself to other tabs:
this.tab_id = this.create_tab_id();
this.tab_id_tiebreaker = Date.now()
this.pending_movement = [0, 0];
this.listeners = {};
window.addEventListener("unload", this.window_onunload);
// 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. On mobile, do this on any touch.
window.addEventListener(mobile? "pointerdown":"focus", (e) => {
this.finalize_quick_view_image();
}, { capture: true });
media_cache.addEventListener("mediamodified", ({media_id}) => { this.broadcast_illust_changes(media_id); });
this.send_image_channel.addEventListener("message", this.received_message);
this.broadcast_tab_info();
// Ask other tabs to broadcast themselves, so we can see if we have a conflicting
// tab ID.
this.send_message({ message: "list-tabs" });
}
create_tab_id(recreate=false)
{
// If we have a saved tab ID, use it.
//
// sessionStorage on Android Chrome is broken. Home screen apps should retain session storage
// for that particular home screen item, but they don't. (This isn't a problem on iOS.) Use
// localStorage instead, which means things like linked tabs will link to the device instead of
// the instance. That's usually good enough if you're linking to a phone or tablet.
let storage = ppixiv.android? localStorage:sessionStorage;
if(!recreate && storage.ppixivTabId)
return storage.ppixivTabId;
// Make a new ID, and save it to the session. This helps us keep the same ID
// when we're reloaded.
storage.ppixivTabId = helpers.create_uuid();
return storage.ppixivTabId;
}
finalize_quick_view_image = () =>
{
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");
}
}
messages = new EventTarget();
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.
window_onunload = (e) =>
{
// If we were sending an image to another tab, cancel it if this tab is closed.
this.send_message({
message: "send-image",
action: "cancel",
to: settings.get("linked_tabs", []),
});
}
// Send an image to another tab. action is either "temp-view", to show the image temporarily,
// or "display", to navigate to it.
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 media_info = ppixiv.media_cache.get_media_info_sync(media_id);
let user_id = media_info?.userId;
let user_info = user_id? user_cache.get_user_info_sync(user_id):null;
this.send_message({
message: "send-image",
from: this.tab_id,
to: tab_ids,
media_id: media_id,
action: action, // "temp-view" or "display"
media_info,
user_info: user_info,
origin: window.origin,
}, false);
}
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.
broadcast_image_info(media_id)
{
// Send everything we know about the image, so the receiver doesn't have to
// do a lookup.
let media_info = ppixiv.media_cache.get_media_info_sync(media_id);
let user_id = media_info?.userId;
let user_info = user_id? user_cache.get_user_info_sync(user_id):null;
this.send_message({
message: "image-info",
from: this.tab_id,
media_id: media_id,
media_info,
bookmark_tags: extra_cache.singleton().get_bookmark_details_sync(media_id),
user_info: user_info,
origin: window.origin,
}, false);
}
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(this.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")
{
if(data.from == this.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(this.tab_id_tiebreaker >= data.tab_id_tiebreaker)
{
console.log("Creating a new tab ID due to ID conflict");
this.tab_id = this.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();
}
}
else if(data.message == "list-tabs")
{
// A new tab opened, and is asking for other tabs to broadcast themselves to check for
// tab ID conflicts.
this.broadcast_tab_info();
}
else if(data.message == "send-image")
{
// If this message has illust info or thumbnail info and it's on the same origin,
// register it.
if(data.origin == window.origin)
{
console.log("Registering cached image info");
let user_info = data.user_info;
if(user_info != null)
user_cache.add_user_data(user_info);
let media_info = data.media_info;
if(media_info != null)
ppixiv.media_cache.add_media_info_full(media_info, { preprocessed: true });
}
// 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")
{
if(data.origin != window.origin)
return;
// update_media_info will trigger mediamodified below. Make sure we don't rebroadcast
// info that we're receiving here. Note that add_media_info_full 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 media_info = data.media_info;
if(media_info != null)
ppixiv.media_cache.update_media_info(data.media_id, media_info);
let bookmark_tags = data.bookmark_tags;
if(bookmark_tags != null)
extra_cache.singleton().update_cached_bookmark_image_tags(data.media_id, bookmark_tags);
let user_info = data.user_info;
if(user_info != null)
user_cache.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. We have to work around a stupid pair of bugs: Safari
// doesn't handle setting movementX/movementY in the constructor, and Firefox
// *only* handles it that way, throwing an error if you try to set it manually.
let event = new PointerEvent("quickviewpointermove", {
movementX: data.x,
movementY: data.y,
});
if(event.movementX == null)
{
event.movementX = data.x;
event.movementY = data.y;
}
window.dispatchEvent(event);
}
}
broadcast_tab_info = () =>
{
let our_tab_info = {
message: "tab-info",
tab_id_tiebreaker: this.tab_id_tiebreaker,
};
this.send_message(our_tab_info);
}
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.
hide_preview_image()
{
let was_in_preview = ppixiv.history.virtual;
if(!was_in_preview)
return;
ppixiv.history.back();
}
send_mouse_movement_to_linked_tabs(x, y)
{
if(!settings.get("linked_tabs_enabled"))
return;
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();
this.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.widget
{
constructor({...options})
{
super({...options,
classes: "link-tab-popup",
template: \`
\`});
}
// 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;
ppixiv.send_image.send_message({
message: "show-link-tab",
linked_tabs: settings.get("linked_tabs", []),
});
}
visibility_changed()
{
super.visibility_changed();
if(!this.visible)
{
ppixiv.send_image.send_message({ message: "hide-link-tab" });
return;
}
helpers.interval(this.send_link_tab_message, 1000, this.visibility_abort.signal);
// Refresh the "unlink all tabs" button on other tabs when the linked tab list changes.
settings.addEventListener("linked_tabs", this.send_link_tab_message, { signal: this.visibility_abort.signal });
// The other tab will send these messages when the link and unlink buttons
// are clicked.
ppixiv.send_image.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.visibility_abort.signal });
ppixiv.send_image.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();
}, { signal: this.visibility_abort.signal });
}
}
ppixiv.link_this_tab_popup = class extends ppixiv.dialog_widget
{
constructor({...options}={})
{
super({...options,
classes: "link-this-tab-popup",
dialog_template: true,
remove_on_exit: false,
dialog_type: "small",
// This dialog is closed when the sending tab closes the link tab interface.
allow_close: false,
visible: false,
template: \`
\${ helpers.create_box_link({ label: "Link this tab", classes: ["link-this-tab"]}) }
\${ helpers.create_box_link({ label: "Unlink this tab", classes: ["unlink-this-tab"]}) }
\`});
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.
ppixiv.send_image.add_message_listener("show-link-tab", (message) => {
this.other_tab_id = message.from;
this.hide_timer.set(2000);
let linked = message.linked_tabs.indexOf(ppixiv.send_image.tab_id) != -1;
this.container.querySelector(".link-this-tab").hidden = linked;
this.container.querySelector(".unlink-this-tab").hidden = !linked;
this.visible = true;
});
ppixiv.send_image.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) => {
ppixiv.send_image.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) => {
ppixiv.send_image.send_message({ message: "unlink-this-tab", to: [this.other_tab_id] });
});
}
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,
dialog_template: true,
classes: "send-image-popup",
remove_on_exit: false,
show_close_button: false,
dialog_type: "small",
template: \`
Click a

tab to send the image there
\`});
// 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;
});
ppixiv.send_image.add_message_listener("take-image", (message) => {
let tab_id = message.from;
ppixiv.send_image.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)
{
ppixiv.send_image.send_message({ message: "hide-send-image" });
return;
}
helpers.interval(() => {
// We should always be visible when this is called.
console.assert(this.visible);
ppixiv.send_image.send_message({ message: "show-send-image" });
}, 1000, this.visibility_abort.signal);
}
}
ppixiv.send_here_popup = class extends ppixiv.dialog_widget
{
constructor({...options}={})
{
super({...options,
classes: "send-image-here-popup",
dialog_template: true,
remove_on_exit: false,
visible: false,
dialog_type: "small",
// This dialog is closed when the sending tab closes the send image interface.
allow_close: false,
template: \`
\${ helpers.create_box_link({ label: "Click to send image here", classes: ["link-this-tab"]}) }
\`});
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.
ppixiv.send_image.add_message_listener("show-send-image", (message) => {
this.other_tab_id = message.from;
this.hide_timer.set(2000);
this.visible = true;
});
ppixiv.send_image.add_message_listener("hide-send-image", (message) => {
this.hide_timer.clear();
this.visible = false;
});
}
take_image = (e) =>
{
// Send take-image. The sending tab will respond with a send-image message.
ppixiv.send_image.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/r151/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();
if(!ppixiv.native)
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 singletons.
ppixiv.settings = new ppixiv.Settings();
ppixiv.media_cache = new ppixiv.MediaCache();
ppixiv.user_cache = new ppixiv.UserCache();
ppixiv.send_image = new ppixiv.SendImage();
// 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;
// Set up iOS movementX/movementY handling.
ppixiv.PointerEventMovement.get;
// 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;
}
// 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);
helpers.set_class(document.documentElement, "mobile", ppixiv.mobile);
helpers.set_class(document.documentElement, "ios", ppixiv.ios);
helpers.set_class(document.documentElement, "android", ppixiv.android);
// On mobile, disable long press opening the context menu and starting drags.
if(ppixiv.mobile)
{
window.addEventListener("contextmenu", (e) => { e.preventDefault(); });
window.addEventListener("dragstart", (e) => { e.preventDefault(); });
}
// 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)
user_cache.add_user_data(preload.user[preload_user_id]);
for(var preload_illust_id in preload.illust)
media_cache.add_media_info_full(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. We handle this ourself.
window.history.scrollRestoration = "manual"; // not ppixiv.history
// 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", \`
html {
--dark-noise: url("\${resources['resources/noise.png']}");
}
\`);
// Load our icon font. var() doesn't work for font-face src, so we have to do
// this manually.
document.head.appendChild(helpers.create_style(\`
@font-face {
font-family: 'ppixiv';
src: url(\${resources['resources/ppixiv.woff']}) format('woff');
font-weight: normal;
font-style: normal;
font-display: block;
}
\`));
// Add the main stylesheet.
{
let link = document.realCreateElement("link");
link.href = resources['resources/main.scss'];
link.rel = "stylesheet";
document.querySelector("head").appendChild(link);
// Wait for the stylesheet to actually load before continuing. This is quick, but if we
// continue before it's ready, we can flash unstyled content or have other weird nondeterministic
// problems.
await helpers.wait_for_load(link);
}
// If we're running natively, index.html included an initial stylesheet to set the background
// color. Remove it now that we have our real stylesheet.
let initial_stylesheet = document.querySelector("#initial-style");
if(initial_stylesheet)
initial_stylesheet.remove();
// Create the shared title and page icon.
document.head.appendChild(document.createElement("title"));
var document_icon = document.head.appendChild(document.createElement("link"));
document_icon.setAttribute("rel", "icon");
helpers.add_clicks_to_search_history(document.body);
this.container = document.body;
// Create the popup menu handler.
this.context_menu = new main_context_menu({container: document.body});
this.link_this_tab_popup = new link_this_tab_popup();
this.send_here_popup = new send_here_popup();
this.send_image_popup = new send_image_popup();
// Create the main progress bar.
this.progress_bar = new progress_bar({ container: this.container });
// Create the screens.
this.screen_search = new screen_search({ container: document.body });
this.screen_illust = new screen_illust({ container: document.body });
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({remove_search_page=false}={})
{
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, {force: true, remove_search_page});
// 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 data source for the current URL.
let data_source = page_manager.singleton().create_data_source_for_url(ppixiv.location);
// 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(args);
let page = args.hash.has("page")? parseInt(args.hash.get("page"))-1:0;
media_id = helpers.get_media_id_for_page(media_id, page);
// 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.documentElement.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;
}
}
// Return the URL to display a media ID.
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";
}
let old_media_id = this.data_source.get_current_media_id(args);
let [old_illust_id] = helpers.media_id_to_illust_id_and_page(old_media_id);
// 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");
}
// If we were viewing a muted image and we're navigating away from it, remove view-muted so
// we're muting images again. Don't do this if we're navigating between pages of the same post.
if(illust_id != old_illust_id)
args.hash.delete("view-muted");
return args;
}
// 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, 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(helpers.args.location);
if(media_id == null)
return false;
let info = media_cache.get_media_info_sync(media_id, { full: false });
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(helpers.args.location);
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.fetch_document(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);
// Redirect to no-ppixiv, to reload the page disabled so we don't leave the user
// on a blank page. If this is a page where Pixiv itself requires a login (which
// is most of them), the initial page request will redirect to the login page before
// we launch, but we can get here for a few pages.
let disabled_url = new URL(document.location);
if(disabled_url.hash != "#no-ppixiv")
{
disabled_url.hash = "#no-ppixiv";
document.location = disabled_url.toString();
}
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";
}
// When viewing an image, toggle the slideshow on or off.
toggle_slideshow()
{
// Add or remove slideshow=1 from the hash. If we're not on the illust view, use
// the URL of the image the user clicked, otherwise modify the current URL.
let args = helpers.args.location;
let viewing_illust = this.current_screen_name == "illust";
if(viewing_illust)
args = helpers.args.location;
else
args = this.get_media_url(this.media_id);
let enabled = args.hash.get("slideshow") == "1";
if(enabled)
args.hash.delete("slideshow");
else
args.hash.set("slideshow", "1");
// If we're on the illust view this replaces the current URL since it's just a
// settings change, otherwise this is a navigation.
helpers.set_page_url(args, !viewing_illust, "toggle slideshow");
}
};
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r151/src/main.js
`;
ppixiv.resources["src/slideshow.js"] = `// This handles the nitty slideshow logic for on_click_viewer.
//
// Slideshows can be represented as pans, which is the data editing_pan edits
// and that we save to images. This data is resolution and aspect-ratio independant,
// so it can be applied to different images and used generically.
//
// Slideshows are built into animations using get_animation, which converts it
// to an animation based on the image's aspect ratio, the screen's aspect ratio,
// the desired speed, etc.
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;
}
// Create the default animation.
get_default_animation()
{
// If we're in slideshow mode, see if we have a different default animation. Panning
// mode always pans.
let slideshow_default = ppixiv.settings.get("slideshow_default", "pan");
if(this.slideshow_enabled && slideshow_default == "contain")
return this.get_animation(ppixiv.slideshow.pan.stationary);
// Choose which default to use.
let animation = this.slideshow_enabled? ppixiv.slideshow.pans.default_slideshow:ppixiv.slideshow.pans.default_pan;
// If the default animation doesn'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.
animation = this.get_animation(animation);
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.get_animation(ppixiv.slideshow.pan.pull_in);
}
static pans =
{
// This is like the thumbnail animation.
default_pan: Object.freeze({
start_zoom: 1,
end_zoom: 1,
x1: 0, y1: 0,
x2: 1, y2: 1,
}),
// Zoom from the bottom-left to the top-right, with a slight zoom-in at the beginning.
// For most images, either the horizontal or vertical part of the pan is usually dominant
// and the other goes away, depending on the aspect ratio. The zoom keeps the animation
// from being completely linear. We don't move all the way to the top, since for many
// portrait images that's too far and causes us to pan past the face, fading away while
// looking at the background.
//
// This gives a visually interesting slideshow that works well for most images, and isn't
// very sensitive to aspect ratio and usually does something reasonable whether the image
// or monitor are in landscape or portrait.
default_slideshow: Object.freeze({
start_zoom: 1.25,
end_zoom: 1,
x1: 0, y1: 1,
x2: 1, y2: 0.1,
}),
// Display the image statically without panning.
stationary: Object.freeze({
start_zoom: 0,
end_zoom: 0,
x1: 0.5, y1: 0,
x2: 0.5, y2: 0,
}),
// This zooms from "contain" to a slight zoom over "cover".
pull_in: Object.freeze({
start_zoom: 0,
end_zoom: 1.2,
x1: 0.5, y1: 0,
x2: 0.5, y2: 0,
}),
}
// Load a saved animation created with PanEditor.
get_animation(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);
}
// 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 };
}
// 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({x: p0.computed_tx, y: p0.computed_ty}, {x: p1.computed_tx, y: 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/r151/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;
// Request permission storage the first time the user saves image edits. Browsers
// seem to handle not spamming requests for this, but for safety we only do this once
// per session. We don't need to wait for this.
if(!this.requested_persistent_storage && navigator.storage?.persist)
{
this.requested_persistent_storage = true;
navigator.storage.persist();
}
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)
{
let data = key_storage.async_store_get(store, media_id);
if(data)
promises[media_id] = data;
}
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;
}) ?? [];
}
// Export the database contents to allow the user to back it up.
async export()
{
if(this.db == null)
throw new Error("extra_image_data is disabled");
let data = await this.db.db_op(async (db) => {
let store = this.db.get_store(db);
let cursor = store.openCursor();
let results = [];
for await (let entry of cursor)
{
// We store pages in the key as a media_id. Add it to the exported value.
results.push({
media_id: entry.key,
...entry.value,
});
}
return results;
}) ?? [];
let exported_data = {
type: "ppixiv-image-data",
data,
};
if(exported_data.data.length == 0)
{
message_widget.singleton.show("No edited images to export.");
return;
}
let json = JSON.stringify(exported_data, null, 4);
let blob = new Blob([json], { type: "application/json" });
helpers.save_blob(blob, "ppixiv image edits.json");
}
// Import data exported by export(). This will overwrite any overlapping entries, but entries
// won't be deleted if they don't exist in the input.
async import()
{
if(this.db == null)
throw new Error("extra_image_data is disabled");
// This API is annoying: it throws an exception (rejects the promise) instead of
// returning null. Exceptions should be used for unusual errors, not for things
// like the user cancelling a file dialog.
let files;
try {
files = await unsafeWindow.showOpenFilePicker({
multiple: false,
types: [{
description: 'Exported image edits',
accept: {
'application/json': ['.json'],
}
}],
});
} catch(e) {
return;
}
let file = await files[0].getFile();
let data = JSON.parse(await file.text());
if(data.type != "ppixiv-image-data")
{
message_widget.singleton.show(\`The file "\${file.name}" doesn't contain exported image edits.\`);
return;
}
let data_by_media_id = {};
for(let entry of data.data)
{
let media_id = entry.media_id;
delete entry.media_id;
data_by_media_id[media_id] = entry;
}
console.log(\`Importing data:\`, data);
await this.db.multi_set(data_by_media_id);
// Tell image_data that we've replaced extra data, so any loaded images are updated.
for(let [media_id, data] of Object.entries(data_by_media_id))
media_cache.replace_extra_data(media_id, data);
message_widget.singleton.show(\`Imported edits for \${data.data.length} \${data.data.length == 1? "image":"images"}.\`);
}
}
//# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r151/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/mobile_illust_ui.js",
"src/create_zip.js",
"src/data_sources.js",
"src/encode_mkv.js",
"src/hide_mouse_cursor_on_idle.js",
"src/media_cache.js",
"src/media_cache_mappings.js",
"src/extra_cache.js",
"src/user_cache.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/search_view.js",
"src/image_ui.js",
"src/tag_search_dropdown_widget.js",
"src/recently_seen_illusts.js",
"src/tag_translations.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;
env.android = navigator.userAgent.indexOf('Android') != -1;
env.mobile = env.ios || env.android;
// 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({});