I've updated some patch from the latest version:
- change light mode to standard dark mode ( hardcoded )
- fix header height issue
To apply it, edit your currently installed dbex userscript and replace all of it with this one:
dbex userscript
// ==UserScript== // @name Danbooru EX // @version 2018.08.21@19.56.54 // @namespace https://github.com/evazion/danbooru-ex // @source https://github.com/evazion/danbooru-ex // @description Danbooru UI Enhancements // @author evazion // @match *://*.donmai.us/* // @grant none // @run-at document-body // @downloadURL https://github.com/evazion/danbooru-ex/raw/stable/dist/danbooru-ex.user.js // @require https://raw.githubusercontent.com/jquery/jquery-ui/1.12.1/ui/widgets/selectable.js // @require https://raw.githubusercontent.com/jquery/jquery-ui/1.12.1/ui/widgets/tooltip.js // @require https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.19.1/moment.js // @require https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.js // @require https://unpkg.com/filesize@3.5.11 // @require https://unpkg.com/css-element-queries@0.3.2/src/ResizeSensor.js // @require https://cdnjs.cloudflare.com/ajax/libs/qtip2/3.0.3/jquery.qtip.js // @require https://unpkg.com/mousetrap@1.6.0/mousetrap.js // @require https://unpkg.com/mousetrap@1.6.0/plugins/record/mousetrap-record.js // @require https://unpkg.com/mousetrap@1.6.0/plugins/global-bind/mousetrap-global-bind.js // ==/UserScript== /* * What is a userscript? A miserable pile of hacks. */ console.log("Danbooru EX:", GM_info.script.version); // console.time("loaded"); // console.time("preinit"); // console.time("initialized"); var danbooruEX = (function (_,Mousetrap,moment,ResizeSensor) { 'use strict'; function ___$insertStyle(css) { if (!css) { return; } if (typeof window === 'undefined') { return; } var style = document.createElement('style'); style.setAttribute('type', 'text/css'); style.innerHTML = css; document.head.appendChild(style); return css; } _ = _ && _.hasOwnProperty('default') ? _['default'] : _; Mousetrap = Mousetrap && Mousetrap.hasOwnProperty('default') ? Mousetrap['default'] : Mousetrap; moment = moment && moment.hasOwnProperty('default') ? moment['default'] : moment; ResizeSensor = ResizeSensor && ResizeSensor.hasOwnProperty('default') ? ResizeSensor['default'] : ResizeSensor; class Setting { constructor({ value, help, configurable, storage } = {}) { this.value = value; this.help = help; this.configurable = configurable; this.storage = storage; } static Session({ value } = {}) { return new Setting({ value, help: "none", configurable: false, storage: window.sessionStorage }); } static Shared({ value = true, help, configurable = true } = {}) { return new Setting({ value, help, configurable, storage: window.localStorage }); } } class Config { static get Items() { return { schemaVersion: Setting.Shared({ configurable: false, value: 2 }), enableHeader: Setting.Shared({ help: "Enable header bar containing search box and mode menu.", }), enableModeMenu: Setting.Shared({ help: "Enable mode menu in header bar, disable mode menu in the sidebar. Required for preview panel.", }), enablePreviewPanel: Setting.Shared({ help: "Enable the post preview panel. Requires header bar and mode menu to be enabled.", }), defaultPreviewMode: Setting.Shared({ help: "Open new tabs in preview mode", value: false, }), enableHotkeys: Setting.Shared({ help: "Enable additional keyboard shortcuts.", }), enableLargeThumbnails: Setting.Shared({ configurable: false, help: "Enable extra large thumbnails (experimental; bandwidth intensive).", value: false, }), largeThumbnailSize: Setting.Shared({ configurable: false, help: "The size (in pixels) of large thumbnails.", value: 229, }), showThumbnailPreviews: Setting.Shared({ help: "Show post preview tooltips when hovering over thumbnails.", }), showPostLinkPreviews: Setting.Shared({ help: "Show post preview tooltips when hovering over post #1234 links.", }), enableNotesLivePreview: Setting.Shared({ help: "Automatically update note preview as you edit.", }), usernameTooltips: Setting.Shared({ help: "Enable tooltips on usernames", value: false, }), styleWikiLinks: Setting.Shared({ help: "Colorize tags in the wiki and forum, underline links to empty tags, and add tooltips to tags.", }), useRelativeTimestamps: Setting.Shared({ help: 'Replace fixed times ("2016-08-10 23:25") with relative times ("3 months ago").', }), resizeableSidebars: Setting.Shared({ help: "Make the tag sidebar resizeable (drag edge to resize).", }), autoplayVideos: Setting.Shared({ help: "Enable autoplay for webm and mp4 posts (normally enabled by Danbooru).", }), loopVideos: Setting.Shared({ help: "Enable looping for video_with_sound posts (normally disabled by Danbooru).", }), muteVideos: Setting.Shared({ help: "Mute video_with_sound posts by default.", value: false, }), artistsRedesign: Setting.Shared({ help: "Enable the redesigned /artists index.", }), commentsRedesign: Setting.Shared({ help: "Enable comment scores and extra info on posts in /comments", }), postsRedesign: Setting.Shared({ help: 'Move artist tags to the top of the tag list, put tag counts next to tag list headers, and add hotkeys for rating / voting on posts.', }), postVersionsRedesign: Setting.Shared({ help: "Add thumbnails on the /post_versions page", }), wikiRedesign: Setting.Shared({ help: "Make header sections in wiki entries collapsible and add table of contents to long wiki pages", }), usersRedesign: Setting.Shared({ help: "Add expandable saved searches to user account pages", }), thumbnailPreviewDelay: Setting.Shared({ configurable: false, help: "The delay in milliseconds when hovering over a thumbnail before the preview appears.", value: 650, }), tagScripts: Setting.Shared({ configurable: false, value: _.fill(Array(10), "") }), defaultSidebarWidth: Setting.Session({ value: 210 }), defaultPreviewPanelWidth: Setting.Session({ value: 785 }), sidebarState: Setting.Session({ value: {} }), previewPanelState: Setting.Session({ value: {} }), modeMenuState: Setting.Session({ value: {} }), tagScriptNumber: Setting.Session({ value: 1 }), headerFixed: Setting.Shared({ value: true }), }; } constructor() { this.migrate(); if ($("#c-users #a-edit").length) { this.initializeForm(); } } migrate() { if (Config.Items.schemaVersion.storage["EX.config.schemaVersion"] === undefined) { this.reset(); } this.schemaVersion = Config.Items.schemaVersion.value; } get(key) { const item = Config.Items[key]; const value = JSON.parse(_.defaultTo( item.storage["EX.config." + key], JSON.stringify(item.value) )); EX.debug(`[CFG] READ EX.config.${key}:`, value); return value; } set(key, value) { const item = Config.Items[key]; item.storage["EX.config." + key] = JSON.stringify(value); EX.debug(`[CFG] SAVE EX.config.${key} =`, value); return this; } get all() { return _.mapValues(Config.Items, (v, k) => this.get(k)); } reset() { _(Config.Items).each((item, key) => { EX.debug(`[CFG] DELETE EX.config.${key}`); delete item.storage["EX.config." + key]; }); return this; } pageKey() { const controller = $("#page > div:nth-child(2)").attr("id"); const action = $("#page > div:nth-child(2) > div").attr("id"); return `${controller} ${action}`; } initializeForm() { const settingsHtml = _(Config.Items) .map((props, name) => _.merge(props, { name })) .filter("configurable") .map(setting => this.renderOption(setting)) .join(""); $("#advanced-settings-section").after(` <fieldset id="ex-settings-section" style="/*display: none*/"> ${settingsHtml} <div class="input"> <label><a href="#" id="factory_reset">Factory Reset</a></label> <div class="hint">Clear all Danbooru EX data and reset settings to default state.</div> </div> </fieldset> `); $("#edit-options > a:nth-child(2)").after('| <a href="#ex-settings">EX Settings</a>'); let $tabs = $("#edit-options a:not(#delete-account):not(#change-password)"); $tabs.off("click").click(e => { $tabs.removeClass("active"); $(e.target).addClass("active"); $("#a-edit form fieldset").hide(); $($(e.target).attr("href") + "-section").show(); e.preventDefault(); }); $("#ex-settings-section input").change(e => { const name = $(e.target).attr("name"); const value = e.target.checked; this.set(name, value); Danbooru.Utility.notice("Setting saved."); }); $("#factory_reset").click(e => { confirm('Reset Danbooru EX settings?') && this.reset() && Danbooru.Utility.notice("Danbooru EX reset."); }); } renderOption(setting) { const name = setting.name; const id = "ex_config_" + _.snakeCase(name); const value = this.get(name) ? "checked" : ""; return ` <div class="input checkbox optional"> <input class="boolean optional" type="checkbox" ${value} name="${_.camelCase(name)}" id="${id}"> <label class="boolean optional" for="${id}">${_.startCase(name)}</label> <div class="hint">${setting.help}</div> </div> `; } } // Define getters/setters for `Config.showHeaderBar` et al. for (let k of _.keys(Config.Items)) { const key = k; Object.defineProperty(Config.prototype, key, { get: function () { return this.get(key) }, set: function (v) { return this.set(key, v) }, enumerable: true, configurable: true, }); } class DText { static createExpandable(name, content) { const $expandable = $(` <div class="expandable"> <div class="expandable-header"> <span>${_.escape(name)}</span> <input type="button" value="Show" class="expandable-button"> </div> <div class="expandable-content" style="display: none"> ${content} </div> </div> `); // If our script runs before Danbooru's scripts do, Danbooru will find our // expandable and add it's own click handler on top of ours. So delay // adding our handler to make sure we overwrite Danbooru's own handler. $(function () { $expandable.find('.expandable-button').off("click").click(e => { $(e.target).closest('.expandable').find('.expandable-content').fadeToggle('fast'); $(e.target).val((_$$1, val) => val === 'Show' ? 'Hide' : 'Show'); e.preventDefault(); }); }); return $expandable; } } class Resource { constructor(object) { Object.assign(this, object); } static async request(type, url, params = {}) { const query = `${url}?${decodeURIComponent($.param(params))}`; // console.time(`${type} ${query}`); const request = $.ajax({ url, type: "POST", data: Object.assign({}, params, { _method: type })}); const response = await request; EX.debug(`[NET] ${request.status} ${request.statusText} ${query}`, request); if (Array.isArray(response)) { return response.map(r => new this(r)); } else { return new this(response); } } static put(id, params = {}) { return this.request("PUT", `${this.controller}/${id}.json`, params); } static get(id, params = {}) { return this.request("GET", `${this.controller}/${id}.json`, params); } static index(params = {}) { return this.request("GET", `${this.controller}.json`, params); } static search(values, otherParams) { const key = this.primaryKey; const batchedValues = _(values).sortBy().sortedUniq().chunk(1000).value(); const requests = batchedValues.map(batch => { const params = _.merge(this.searchParams, { search: otherParams }, { search: { [key]: batch.join(",") }}); return this.index(params); }); return Promise.all(requests).then(_.flatten); } static get searchParams() { return { limit: 1000 }; } static get controller() { return "/" + _.snakeCase(this.name + "s"); } } var Post = Resource.Post = class Post extends Resource { static get primaryKey() { return "post"; } get tags() { let split_tag_string = (tag_string, category) => { return tag_string.split(/\s+/).filter(String).map(name => ({ name, category })); }; return _.concat( split_tag_string(this.tag_string_artist, 1), split_tag_string(this.tag_string_copyright, 3), split_tag_string(this.tag_string_character, 4), split_tag_string(this.tag_string_meta, 5), split_tag_string(this.tag_string_general, 0), ); } static update(postId, tags) { return this.put(postId, { "post[old_tag_string]": "", "post[tag_string]": tags }); } get source_domain() { try { const hostname = new URL(this.source).hostname; const domain = hostname.match(/([^.]*)\.([^.]*)$/)[0]; return domain; } catch (_e) { return ""; } } get source_link() { const maxLength = 10; const truncatedSource = this.source.replace(new RegExp(`(.{${maxLength}}).*$`), "$1..."); if (this.source.match(/^https?:\/\//)) { return `<a href="${_.escape(this.source)}">${this.source_domain}</a>`; } else if (this.source.trim() !== "") { return `<i>${_.escape(truncatedSource)}</i>`; } else { return "<i>none</i>"; } } get pretty_rating() { switch (this.rating) { case "s": return "Safe"; case "q": return "Questionable"; case "e": return "Explicit"; } } }; var Tag = Resource.Tag = class Tag extends Resource { static get Categories() { return [ "General", // 0 "Artist", // 1 undefined, // 2 (unused) "Copyright", // 3 "Character", // 4 "Meta" // 5 ]; } static get searchParams() { return _.merge({}, super.searchParams, { search: { hide_empty: "no" }}); } static get primaryKey() { return "name"; } static renderTag(tag) { const href = `/posts?tags=${encodeURIComponent(tag.name)}`; return `<a class="search-tag tag-type-${tag.category}" href="${href}">${_.escape(tag.name)}</a>`; } static renderTagList(tags, classes) { return ` <section class="ex-tag-list ${classes}"> <h1>Tags</h1> <ul>${tags.map(Tag.renderTagListItem).join("")}</ul> </section> `; } static renderTagListItem(tag) { return `<li class="category-${tag.category}">${Tag.renderTag(tag)}</li>`; } static renderSearchTagListItem(tag) { return ` <li class="category-${tag.category}"> <a class="wiki-link" href="/wiki_pages/show_or_new?title=${encodeURIComponent(tag.name)}">?</a> ${Tag.renderTag(tag)} <span class="post-count">${tag.post_count}</span> </li> `; } }; var TagImplication = Resource.TagImplication = class TagImplication extends Resource { static get primaryKey() { return "id"; } }; var User = Resource.User = class User extends Resource { static get primaryKey() { return "id"; } static render(user) { let classes = "user-" + user.level_string.toLowerCase(); if (user.can_approve_posts) { classes += " user-post-approver"; } if (user.can_upload_free) { classes += " user-post-uploader"; } if (user.is_super_voter) { classes += " user-super-voter"; } if (user.is_banned) { classes += " user-banned"; } if (Danbooru.Utility.meta("style-usernames") === "true") { classes += " with-style"; } return ` <a class="${classes}" href="/users/${user.id}">${_.escape(user.name)}</a> `; } }; class Posts { static initialize() { if ($("#c-posts #a-show").length === 0) { return; } $("#image").addClass("ex-fit-width"); Posts.initializeResize(); // Posts.initializeImplications(); Posts.initializeTagList(); Posts.initializeHotkeys(); Posts.initializeVideo(); } // Resize notes/ugoira controls as window is resized. static initializeResize() { new ResizeSensor($('#image-container'), () => { $("#image-resize-to-window-link").click(); }); } static initializeLargeThumbnails() { $("article.post-preview").each((i, e) => { const $post = $(e); const $img = $post.find("img"); const data = Posts.normalize($post.data()); const src = `${data.large_file_url}`; // const src = `//danbooru.s3.amazonaws.com/${data.md5}.${data.file_ext}`; const size = `${EX.config.largeThumbnailSize}px`; $post.css({ "width": size, "height": size }); $img.css({ "width": size, "height": size, "object-fit": "contain" }); $img.attr("src", src); }); } static initializeTagList() { _.forOwn({ "Artist": "artist", "Copyrights": "copyright", "Characters": "character", "Tags": "general", "Meta": "meta", }, (category, heading) => { let $header = $('#tag-list :header').filter((i, e) => $(e).text().match(heading)); let $tags = $header.next('ul'); $tags.addClass(`ex-${category}-tag-list`); $header.wrap(`<span class="ex-tag-list-header ex-${category}-tag-list-header">`); $header.parent().append(`<span class="post-count">${$tags.children().length}</span>`); }); } static initializeImplications() { let $tags = $('#tag-list'); let tag_string = $("#image-container").data("tags"); TagImplication.index({ search: { antecedent_name: tag_string }}).then(implications => { let implied_tag_names = _(implications).map('descendant_names').flatMap(str => str.split(" ")).sort().uniq().value(); Tag.search(implied_tag_names).then(implied_tags => { let sortCategory = (category) => category === 1 ? 1 // artist : category === 2 ? 2 // copyright : category === 4 ? 3 // character : category === 0 ? 4 // general : 0; // other implied_tags = _.sortBy(implied_tags, [(tag) => sortCategory(tag.category), "name"]); $tags.append(` <h2>Implied Tags</h2> <ul> ${implied_tags.map(Tag.renderSearchTagListItem).join("")} </ul> `); }); }); } /* * Alt+S: Rate Safe. * Alt+Q: Rate Questionable. * Alt+E: Rate Explicit. * U / Alt+U: Vote up / vote down. */ static initializeHotkeys() { const post_id = Danbooru.Utility.meta("post-id"); const rate = function (post_id, rating) { return function (e) { Danbooru.Post.update(post_id, {"post[rating]": rating}); e.preventDefault(); }; }; $(document).keydown("alt+s", rate(post_id, 's')); $(document).keydown("alt+q", rate(post_id, 'q')); $(document).keydown("alt+e", rate(post_id, 'e')); $(document).keydown("u", () => Danbooru.Post.vote('up', post_id)); $(document).keydown("alt+u", () => Danbooru.Post.vote('down', post_id)); } static initializeVideo() { const $video = $("video#image").get(0); if ($video) { $video.autoplay = EX.config.autoplayVideos; $video.muted = EX.config.muteVideos; $video.loop = EX.config.loopVideos; } } // Convert the object returned by $(post).data() to an object with the same // properties that the JSON API returns. static normalize(data) { let post = _.mapKeys(data, (v, k) => _.snakeCase(k)); post.md5 = post.md_5; const flags = post.flags.split(/\s+/); post.is_pending = _.indexOf(flags, "pending") !== -1; post.is_flagged = _.indexOf(flags, "flagged") !== -1; post.is_deleted = _.indexOf(flags, "deleted") !== -1; post.has_visible_children = post.has_children; post.tag_string = post.tags; post.pool_string = post.pools; post.status_flags = post.flags; post.image_width = post.width; post.image_height = post.height; return post; } // Generate the post thumbnail HTML. static preview(post, { size="preview", classes=[] } = {}) { let preview_class = "post-preview ex-post-preview"; preview_class += " " + classes.join(" "); if (size === "preview") { preview_class += post.is_pending ? " post-status-pending" : ""; preview_class += post.is_flagged ? " post-status-flagged" : ""; preview_class += post.is_deleted ? " post-status-deleted" : ""; preview_class += post.parent_id ? " post-status-has-parent" : ""; preview_class += post.has_visible_children ? " post-status-has-children" : ""; } const data_attributes = ` data-id="${post.id}" data-has-sound="${!!post.tag_string.match(/(video_with_sound|flash_with_sound)/)}" data-tags="${_.escape(post.tag_string)}" data-pools="${post.pool_string}" data-uploader="${_.escape(post.uploader_name)}" data-approver-id="${post.approver_id}" data-rating="${post.rating}" data-width="${post.image_width}" data-height="${post.image_height}" data-flags="${post.status_flags}" data-parent-id="${post.parent_id}" data-has-children="${post.has_children}" data-score="${post.score}" data-views="${post.view_count}" data-fav-count="${post.fav_count}" data-pixiv-id="${post.pixiv_id}" data-md5="${post.md5}" data-file-ext="${post.file_ext}" data-file-url="${post.file_url}" data-large-file-url="${post.large_file_url}" data-preview-file-url="${post.preview_file_url}" `; let src, scale; if (size === "preview") { src = post.preview_file_url; scale = Math.min(150 / post.image_width, 150 / post.image_height); scale = Math.min(1, scale); } else if (size === "large") { src = post.large_file_url; scale = Math.min(1, 850 / post.image_width); } else { src = post.file_url; scale = 1; } const [width, height] = [Math.round(post.image_width * scale), Math.round(post.image_height * scale)]; let media; if (post.file_ext.match(/webm|mp4|zip/) && size != "preview") { const autoplay = (size === "large" || EX.config.autoplayVideos) ? "autoplay" : ""; const loop = (size === "large" || EX.config.loopVideos) ? "loop" : ""; const muted = (size === "large" || EX.config.muteVideos) ? "muted" : ""; media = ` <video ${autoplay} ${loop} ${muted} width="${width}" height="${height}" src="${src}" title="${_.escape(post.tag_string)}"> `; } else { media = ` <img itemprop="thumbnailUrl" width="${width}" height="${height}" src="${src}" title="${_.escape(post.tag_string)}"> `; } // XXX get the tag params from the URL if on /posts. const tag_params = ""; return ` <article itemscope itemtype="http://schema.org/ImageObject" id="post_${post.id}" class="${preview_class}" ${data_attributes}> <a href="/posts/${post.id}${tag_params}">${media}</a> </article> `; } static renderExcerpt(post, uploader) { return ` <section class="ex-excerpt ex-post-excerpt"> <div class="ex-excerpt-title ex-post-excerpt-title"> <span class="post-info id"> Post #${post.id} </span> <span class="separator">·</span> <span class="post-info uploader">${User.render(uploader)}</span> <span class="separator">·</span> <time class="post-info created-at ex-short-relative-time" datetime="${post.created_at}" title="${moment(post.created_at).format()}"> ${moment(post.created_at).locale("en-short").fromNow()} ago </time> <span class="separator">·</span> <span class="post-info up-score"> ${post.up_score} <a href="#"> <i class="fa fa-lg fa-thumbs-o-up" aria-hidden="true"></i> </a> </span> <span class="post-info down-score"> ${post.down_score} <a href="#"> <i class="fa fa-lg fa-thumbs-o-down" aria-hidden="true"></i> </a> </span> <span class="separator">·</span> <span class="post-info fav-count"> <a href="#">${post.fav_count}</a> <a href="#"> <i class="fa fa-lg fa-star-o" aria-hidden="true"></i> </a> </span> </div> <div class="ex-excerpt-body ex-post-excerpt-body"> ${Posts.preview(post, { size: "large", classes: [ "ex-post-excerpt-preview", "ex-no-tooltip" ] })} <div class="ex-post-excerpt-metadata"> ${Tag.renderTagList(post, "ex-tag-list-inline")} </div> </div> </section> `; } } class PreviewPanel { static initialize() { // This is the main content panel that comes before the preview panel. let $content = $(` #c-posts #content, #c-post-appeals #a-index, #c-post-flags #a-index, #c-post-versions #a-index, #c-explore-posts > div, #c-notes #a-index, #c-pools #a-gallery, #c-pools #a-show, #c-comments #a-index, #c-moderator-post-queues #a-show, #c-users #a-show, #c-wiki-pages > div > #content, #c-wiki-page-versions > div > #content `); if ($content.length === 0) { return; } $content.parent().addClass("ex-panel-container"); $content.addClass("ex-content-panel ex-panel"); $content.after(` <div id="ex-preview-panel-resizer" class="ex-vertical-resizer"></div> <section id="ex-preview-panel" class="ex-panel"> <div id="ex-preview-panel-container"> <article class="ex-no-image-selected"> No image selected. Click a thumbnail to open image preview. </article> </div> </section> `); // XXX: sometimes a huge offset is calculated. don't know why. // PreviewPanel.origTop = $("#ex-preview-panel > div").offset().top; PreviewPanel.origTop = 127; const width = _.defaultTo(EX.config.previewPanelState[EX.config.pageKey()], EX.config.defaultPreviewPanelWidth); PreviewPanel.setWidth(width); PreviewPanel.save(); if (ModeMenu.getMode() === "view") { PreviewPanel.$panel.hide(); } $('.ex-mode-menu select[name="mode"]').change(PreviewPanel.switchMode); $("#ex-preview-panel-resizer").draggable({ axis: "x", helper: "clone", drag: _.throttle(PreviewPanel.resize, 16), stop: _.debounce(PreviewPanel.save, 100), }); } static async update($post) { const postId = $post.data("id"); const post = await Post.get(postId); const html = PreviewPanel.renderPost(post); $("#ex-preview-panel > div").children().first().replaceWith(html); } static get $panel() { return $("#ex-preview-panel"); } static resize(e, ui) { // XXX magic number PreviewPanel.setWidth($("body").innerWidth() - ui.position.left - 28); } static save() { let state = EX.config.previewPanelState; state[EX.config.pageKey()] = PreviewPanel.$panel.width(); EX.config.previewPanelState = state; } static opened() { return PreviewPanel.$panel.is(":visible") && PreviewPanel.$panel.width() > 0; } static open() { if (PreviewPanel.$panel.width() === 0) { PreviewPanel.setWidth(EX.config.defaultPreviewPanelWidth); } PreviewPanel.$panel.show({ effect: "slide", direction: "left" }).promise().then(PreviewPanel.save); } static close() { PreviewPanel.$panel.hide({ effect: "slide", direction: "right" }).promise().then(PreviewPanel.save); } static switchMode() { if (ModeMenu.getMode() === "view") { PreviewPanel.close(); } else { PreviewPanel.open(); } } static setWidth(width) { PreviewPanel.$panel.width(width); PreviewPanel.$panel.css({ flex: `0 0 ${width}px` }); $("#ex-preview-panel > div").width(width); } static renderPost(post) { return ` <section class="ex-preview-panel-post"> <div class="ex-preview-panel-post-metadata"> <div class="ex-preview-panel-post-title"> <span class="post-info"> <h1>Score</h1> <span class="fav-count"> ${post.fav_count} <a href="#"> <i class="far fa-heart" aria-hidden="true"></i> </a> </span> <span class="score"> ${post.score} <a href="#"> <i class="far fa-thumbs-up" aria-hidden="true"></i> </a> <a href="#"> <i class="far fa-thumbs-down" aria-hidden="true"></i> </a> </span> </span> <span class="post-info uploader-name"> <h1>User</h1> <a href="/users/${post.uploader_id}">${_.escape(post.uploader_name)}</a> <time class="created-at ex-short-relative-time" datetime="${post.created_at}" title="${moment(post.created_at).format()}"> ${moment(post.created_at).locale("en-short").fromNow()} </time> </span> <span class="post-info rating"> <h1>Rating</h1> ${post.rating.toUpperCase()} </span> <span class="post-info source"> <h1>Source</h1> ${post.source_link} </span> <span class="post-info dimensions"> <h1>Size</h1> ${post.image_width}x${post.image_height} </span> </div> <div class="ex-preview-panel-post-tags"> ${Tag.renderTagList(post.tags, "ex-tag-list-inline")} </div> </div> <div class="ex-preview-panel-post-body"> ${Posts.preview(post, { size: "large", classes: [ "ex-preview-panel-image" ] })} </div> </section> `; } } class Navigation { static gotoPageN(n) { if (location.search.match(/page=(\d+)/)) { location.search = location.search.replace(/page=(\d+)/, `page=${n}`); } else { location.search += `&page=${n}`; } } static gotoPage(event) { Navigation.gotoPageN(Number(event.key)); } static gotoLastPage(event) { // a:not(a[rel]) - exclude the Previous/Next links seen in the paginator on /favorites et al. const n = $('div.paginator li:nth-last-child(2) a:not(a[rel])').first().text(); if (n) { Navigation.gotoPageN(n); } } static gotoPageDialog() { const $dialog = $(` <form> <input id="ex-dialog-input" type="text" placeholder="Enter page number"> <input type="submit" value="Go"> </form> `).dialog({ title: "Go To Page", minHeight: 0, minWidth: 0, resizable: false, modal: true, }); $dialog.submit(() => { const page = $dialog.find('input[type="text"]').val(); Navigation.gotoPageN(page); return false; }); return false; } static goDirection(direction) { var href = $(`.paginator a[rel=${direction}]`).attr("href"); if (href) { window.location = href; } } static goTop() { window.scrollTo(0, 0); } static goBottom() { window.scrollTo(0, $(document).height()); } static goForward() { window.history.forward(); } static goBack() { window.history.back(); } static goNext() { Navigation.goDirection("next"); } static goPrev() { Navigation.goDirection("prev"); } static scroll(direction, duration, distance) { return _.throttle(() => { const top = $(window).scrollTop() + direction * $(window).height() * distance; $('html, body').animate({scrollTop: top}, duration, "linear"); }, duration); } } class ModeMenu { static initialize() { ModeMenu.uninitializeDanbooruModeMenu(); ModeMenu.overrideDanbooruArrowKeys(); ModeMenu.initializeModeMenu(); ModeMenu.initializeTagScriptControls(); ModeMenu.initializeThumbnails(); } static uninitializeDanbooruModeMenu() { Danbooru.PostModeMenu.initialize = _.noop; Danbooru.PostModeMenu.show_notice = _.noop; $(".post-preview a").unbind("click", Danbooru.PostModeMenu.click); $(document).unbind("keydown", "1 2 3 4 5 6 7 8 9 0", Danbooru.PostModeMenu.change_tag_script); $("#sidebar #mode-box").hide(); } // Danbooru's default left / right arrow key bindings conflict with our use // of the arrow keys in tag script / preview mode. Ignore these bindings // during these modes. static overrideDanbooruArrowKeys() { $('[data-shortcut="d right"]').attr("data-shortcut", "d"); $('[data-shortcut="a left"]').attr("data-shortcut", "a"); Danbooru.Shortcuts.initialize_data_shortcuts(); Danbooru.Utility.keydown("left", "keydown.danbooru.arrow_prev_page", _e => ModeMenu.getMode() === "view" && Navigation.goPrev()); Danbooru.Utility.keydown("right", "keydown.danbooru.arrow_next_page", _e => ModeMenu.getMode() === "view" && Navigation.goNext()); } static initializeModeMenu() { $('.ex-mode-menu select[name="mode"]').change(ModeMenu.switchMode); const defaultMode = (EX.config.defaultPreviewMode && EX.config.pageKey() === "c-posts a-index") ? "preview" : "view"; const mode = _.defaultTo(EX.config.modeMenuState[EX.config.pageKey()], defaultMode); ModeMenu.setMode(mode); } static initializeTagScriptControls() { $('.ex-mode-menu input[name="tag-script"]').on( "input", _.debounce(ModeMenu.saveTagScript, 250) ); $('.ex-mode-menu select[name="tag-script-number"]').change(ModeMenu.switchTagScript); ModeMenu.setTagScriptNumber(EX.config.tagScriptNumber); $('.ex-mode-menu button[name="apply"]').click(ModeMenu.applyTagScript); $('.ex-mode-menu button[name="select-all"]').click(ModeMenu.selectAll); $('.ex-mode-menu button[name="select-invert"]').click(ModeMenu.invertSelection); } static initializeThumbnails() { const selector = ` .mod-queue-preview aside a, div.post-preview .preview a, article.post-preview:not(.ex-preview-panel-image) a `; $(document).on("click", selector, ModeMenu.onThumbnailClick); // Hide cursor when clicking outside of thumbnails. $(document).on("click", () => $(".ex-cursor").removeClass("ex-cursor")); } static switchToTagScript(event) { const newN = Number(String.fromCharCode(event.which)); const oldN = ModeMenu.getTagScriptNumber(); if (ModeMenu.getMode() === "tag-script" && newN === oldN) { $('.ex-mode-menu input[name="tag-script"]').select(); } else { ModeMenu.setMode("tag-script"); ModeMenu.setTagScriptNumber(newN); } event.preventDefault(); } static switchMode() { const mode = ModeMenu.getMode(); let state = EX.config.modeMenuState; state[EX.config.pageKey()] = mode; EX.config.modeMenuState = state; $("body").removeClass((i, klass) => (klass.match(/mode-.*/) || []).join(' ')); $("body").addClass(`mode-${mode}`); if (mode === "tag-script") { $(".ex-mode-menu .ex-tag-script-controls").show(); $("#page").selectable({ filter: "article.post-preview, div.post-preview .preview, .mod-queue-preview aside", delay: 200, }); } else { $(".ex-mode-menu .ex-tag-script-controls").hide(); if ($("#page").selectable("instance")) { $("#page").selectable("destroy"); } } } static switchTagScript(event) { const n = ModeMenu.getTagScriptNumber(); EX.config.tagScriptNumber = n; const script = EX.config.tagScripts[n]; $('.ex-mode-menu input[name="tag-script"]').val(script).change(); } static onThumbnailClick(event) { // Only apply on left click, not middle click and not ctrl+left click. if (event.ctrlKey || event.which !== 1) { return true; } // XXX prevent focused text fields from staying focused when clicking on thumbnails. $(":focus").blur(); if (ModeMenu.getMode() === "view") { return true; } else { Selection.moveCursorTo($(event.target), { selectTarget: true, selectInterval: event.shiftKey }); return false; } } static applyTagScript(event) { const mode = ModeMenu.getMode(); if (mode === "tag-script") { const tags = ModeMenu.getTagScript(); const postIds = $(".ui-selected").map((i, e) => $(e).closest(".post-preview").data("id")); ModeMenu.updatePosts(postIds, tags); } } static updatePosts(postIds, tags, updated = 0, total = postIds.length) { const requests = _.map(postIds, postId => { const promise = Promise.resolve(Post.update(postId, tags)); return promise.then(post => { updated++; Danbooru.Utility.notice(`Updated post #${postId} (${total - updated} remaining)`); return { post: post, status: 200 }; }).catch(resp => { return { id: postId, status: resp.status }; }); }); Promise.all(requests).then(posts => { const failedPosts = _(posts).difference(_.filter(posts, { status: 200 })).map("id").value(); const delay = Math.min((failedPosts.length / 4), 3); if (failedPosts.length > 0) { _.delay(() => ModeMenu.updatePosts(failedPosts, tags, updated, total), delay * 1000); } }); } static selectAll(event) { if ($(".ui-selected").length) { $(".ui-selected").removeClass("ui-selected"); } else { $(".ui-selectee").addClass("ui-selected"); } event.preventDefault(); } static invertSelection(event) { let $unselected = $(".ui-selectee:not(.ui-selected)"); let $selected = $(".ui-selectee.ui-selected"); $unselected.addClass("ui-selected"); $selected.removeClass("ui-selected"); } static getMode() { return $(".ex-mode-menu select").val(); } static setMode(mode) { $('.ex-mode-menu select[name="mode"]').val(mode).change(); } static toggleMode(mode) { ModeMenu.setMode(ModeMenu.getMode() === mode ? "view" : mode); } static getTagScript() { return $('.ex-mode-menu input[name="tag-script"]').val().trim(); } static saveTagScript() { const scripts = EX.config.tagScripts; scripts[ModeMenu.getTagScriptNumber()] = ModeMenu.getTagScript(); EX.config.tagScripts = scripts; } static getTagScriptNumber() { return Number($('.ex-mode-menu select[name="tag-script-number"]').val()); } static setTagScriptNumber(n) { $('.ex-mode-menu select[name="tag-script-number"]').val(n).change(); } } class Selection { static get post() { return "article.post-preview, div.post-preview .preview, .mod-queue-preview aside"; } static get $cursor() { return Selection.active() ? $(".ex-cursor") : $(Selection.post).first().addClass("ex-cursor"); } static set $cursor($newCursor) { Selection.$cursor.removeClass("ex-cursor"); return $newCursor.addClass("ex-cursor"); } static active() { return $(".ex-cursor").length > 0; } static between($from, $to) { if ($from.nextAll().is($to)) { return $from.nextUntil($to, Selection.post).add($to).addBack(); } else if ($from.prevAll().is($to)) { return $from.prevUntil($to, Selection.post).add($to).addBack(); } else { return $(); } } static selectBetween($from, $to) { return Selection.between($from, $to).addClass("ui-selected"); } static deselectBetween($from, $to) { return Selection.between($from, $to).removeClass("ui-selected"); } static moveCursor(direction, { selectInterval = false } = {}) { // XXX if ($(Selection.post).length === 0) { if (ModeMenu.getMode() === "view") { return true; } const post = Selection.post; const $cursor = Selection.$cursor; const firstInColumn = $posts => $posts.filter((i, e) => $(e).position().left === $cursor.position().left).first(); const $target = direction === "left" ? $cursor.prev(post) : direction === "right" ? $cursor.next(post) : direction === "up" ? firstInColumn($cursor.prevAll(post)) : direction === "down" ? firstInColumn($cursor.nextAll(post)) : $(); // XXX cleanup if ($target.length) { if (selectInterval) { $cursor.closest(Selection.post).toggleClass("ui-selected"); } Selection.moveCursorTo($target, { selectInterval }); } } static moveCursorTo($target, { selectTarget = false, selectInterval = false } = {}) { const $newCursor = $target.closest(Selection.post); const $oldCursor = $(".ex-cursor").length ? $(".ex-cursor") : $newCursor; const $newMark = $newCursor; const $oldMark = $(".ex-mark").length ? $(".ex-mark") : $(Selection.post).first().addClass("ex-mark"); Selection.swapCursor($oldCursor, $newCursor); if (selectTarget) { $newCursor.toggleClass("ui-selected"); } if (selectInterval) { Selection.deselectBetween($oldMark, $oldCursor); Selection.selectBetween($oldMark, $newCursor); } else { $oldMark.removeClass("ex-mark"); $newMark.addClass("ex-mark"); } } static swapCursor($oldCursor, $newCursor) { const $post = $newCursor.closest(".post-preview"); $oldCursor.removeClass("ex-cursor"); $newCursor.addClass("ex-cursor"); Selection.scrollWindowTo($newCursor); $newCursor.find("a").focus(); PreviewPanel.update($post); } static scrollWindowTo($target) { const targetTop = $target.position().top; const targetHeight = $target.height(); if (targetTop + targetHeight > window.scrollY + window.innerHeight) { window.scrollTo(0, targetTop + 2*targetHeight - window.innerHeight); } else if (targetTop < window.scrollY) { window.scrollTo(0, targetTop - targetHeight); } } static toggleSelected() { if (!Selection.active()) { return true; } Selection.$cursor.toggleClass("ui-selected"); } static open() { if (!Selection.active()) { return true; } window.location = Selection.$cursor.find("a").attr("href"); } static openInNewTab() { if (!Selection.active()) { return true; } window.open(Selection.$cursor.find("a").attr("href")); } static favorite() { if (!Selection.active()) { return true; } const post = Posts.normalize(Selection.$cursor.closest(".post-preview").data()); $.post("/favorites.json", { post_id: post.id }).then(() => Danbooru.Utility.notice(`You have favorited post #${post.id}.`) ); } } class Header { static initialize() { Header.initializeHeader(); if (EX.config.enableModeMenu) { Header.initializeModeMenu(); if (EX.config.enablePreviewPanel) { PreviewPanel.initialize(); } } } static initializeHeader() { let $header = $(Header.render()).insertBefore("#top"); _.defer(() => $header.show()); // Move news announcements inside of EX header. $("#news-updates").insertBefore("#ex-header .ex-header-wrapper"); // Initalize header search box. Header.$tags.val($("#sidebar #tags").val()); Danbooru.Autocomplete && Danbooru.Autocomplete.initialize_all && Danbooru.Autocomplete.initialize_all(); Header.$close.click(Header.toggle); $(document).scroll(_.throttle(Header.onScroll, 16)); } static initializeModeMenu() { $(".ex-mode-menu").show(); ModeMenu.initialize(); } static onScroll() { $("#ex-header").toggleClass("ex-header-scrolled", window.scrollY > 0, { duration: 100 }); // Shrink header after scrolling down. window.scrollY > 0 && $("header h1").addClass("ex-small-header"); } static executeSearchInNewTab() { // XXX if ($("#ex-header #ex-tags:focus").length) { const tags = Header.$tags.val().trim(); window.open(`/posts?tags=${encodeURIComponent(tags)}`, "_blank").focus(); } } static focusSearch() { // Add a space to end if box is non-empty and doesn't already have trailing space. Header.$tags.val().length && Header.$tags.val((i, v) => v.replace(/\s*$/, ' ')); Header.$tags.focus(); return false; } static toggle() { Header.$el.toggleClass("ex-fixed ex-static"); Header.$close.find("i").toggleClass("fa-times-circle fa-thumbtack"); EX.config.headerFixed = !EX.config.headerFixed; } static get $el() { return $("#ex-header"); } static get $close() { return $("#ex-header .ex-header-close"); } static get $tags() { return $("#ex-header #ex-tags"); } static render() { return ` <header style="display: none;" id="ex-header" class="${EX.config.headerFixed ? "ex-fixed" : "ex-static"}"> <div class="ex-header-wrapper"> <h1 class="ex-small-header"><a href="/">Danbooru</a></h1> <form class="ex-search-box" action="/posts" accept-charset="UTF-8" method="get"> <input type="text" data-autocomplete="tag-query" name="tags" id="ex-tags" class="ui-autocomplete-input" autocomplete="off"> <input type="submit" value="Go"> </form> <section class="ex-mode-menu" style="display: none"> <label for="mode">Mode</label> <select name="mode"> <option value="view">View</option> <option value="preview">Preview</option> <option value="tag-script">Tag script</option> </select> <fieldset class="ex-tag-script-controls" style="display: none"> <select name="tag-script-number"> <option value="1">1</option> <option value="2">2</option> <option value="3">3</option> <option value="4">4</option> <option value="5">5</option> <option value="6">6</option> <option value="7">7</option> <option value="8">8</option> <option value="9">9</option> </select> <input id="${EX.config.enableModeMenu ? "tag-script-field" : "" }" name="tag-script" type="text" data-autocomplete="tag-query" placeholder="Enter tag script"> <button name="apply" type="button">Apply</button> <label>Select</label> <button name="select-all" type="button">All/None</button> <button name="select-invert" type="button">Invert</button> </fieldset> </section> <a class="ex-header-close"> <i class="fas fa-lg ${EX.config.headerFixed ? "fa-times-circle" : "fa-thumbtack"}" aria-hidden="true"></i> </a> </div> </header> `; } } class Keys { constructor() { this.actions = {}; this.bindings = []; } register(actions = {}) { this.actions = _.merge({}, this.actions, actions); return this; } bind(bindings) { _(bindings).each(binding => { const keys = _(binding).keys().get(0); const action = binding[keys]; const callback = this.actions[action]; if (action === undefined || callback === undefined) { EX.debug(`[KEY] FAIL ${keys} -> ${action}`); return this; } Mousetrap.bind(keys, event => { EX.debug(`[KEY] EXEC ${keys} -> ${action} (${callback.name})`); return callback(event) === true; }); EX.debug(`[KEY] BIND ${keys} -> ${action} (${callback.name})`); }); this.bindings = _.concat(this.bindings, bindings); return this; } initialize() { // Numpad 5 Mousetrap.addKeycodes({ 12: "clear" }); this.register({ "escape": Keys.escape, "goto-page": Navigation.gotoPage, "goto-last-page": Navigation.gotoLastPage, "goto-page-dialog": Navigation.gotoPageDialog, "go-my-account": () => window.location = `/users/${Danbooru.Utility.meta("current-user-id")}`, "go-my-dmails": () => window.location = "/dmails", "go-my-favorites": () => window.location = `/posts?tags=ordfav:${encodeURIComponent(Danbooru.Utility.meta("current-user-name"))}`, "go-my-saved-searches": () => window.location = `/saved_searches`, "go-my-settings": () => window.location = `/users/${Danbooru.Utility.meta("current-user-id")}/edit`, "go-artists-index": () => window.location = "/artists", "go-bur-new": () => window.location = "/bulk_update_requests/new", "go-comments-index": () => window.location = "/comments", "go-forum-index": () => window.location = "/forum_topics", "go-pools-index": () => window.location = "/pools", "go-post-index": () => window.location = "/posts", "go-wiki-index": () => window.location = "/wiki_pages", "go-top": Navigation.goTop, "go-bottom": Navigation.goBottom, "go-forward": Navigation.goForward, "go-back": Navigation.goBack, "header-toggle": Header.toggle, "header-focus-search": Header.focusSearch, "header-execute-search-in-new-tab": Header.executeSearchInNewTab, "select-all": ModeMenu.selectAll, "invert-selection": ModeMenu.invertSelection, "apply-tag-script": ModeMenu.applyTagScript, "switch-to-tag-script": ModeMenu.switchToTagScript, "set-preview-mode": () => ModeMenu.setMode("preview"), "move-cursor-up": e => Selection.moveCursor("up", { selectInterval: e.shiftKey }), "move-cursor-right": e => Selection.moveCursor("right", { selectInterval: e.shiftKey }), "move-cursor-down": e => Selection.moveCursor("down", { selectInterval: e.shiftKey }), "move-cursor-left": e => Selection.moveCursor("left", { selectInterval: e.shiftKey }), "cursor-open": Selection.open, "cursor-open-in-new-tab": Selection.openInNewTab, "cursor-toggle-selected": Selection.toggleSelected, "cursor-favorite": Selection.favorite, "save-search": () => $("#save-search").click(), }); this.bind([ { "q": "header-focus-search" }, { "h t": "header-toggle" }, { "h h": "header-focus-search" }, { "S": "save-search" }, { "g :": "goto-page-dialog" }, { "g 0": "goto-last-page" }, { "g 1": "goto-page" }, { "g 2": "goto-page" }, { "g 3": "goto-page" }, { "g 4": "goto-page" }, { "g 5": "goto-page" }, { "g 6": "goto-page" }, { "g 7": "goto-page" }, { "g 8": "goto-page" }, { "g 9": "goto-page" }, { "g g": "go-top" }, { "G": "go-bottom" }, { "g f": "go-forward" }, { "g b": "go-back" }, { "g h": "go-my-account" }, { "g d": "go-my-dmails" }, { "g F": "go-my-favorites" }, { "g s": "go-my-settings" }, { "g S": "go-my-saved-searches" }, { "g a": "go-artists-index" }, { "g B": "go-bur-new" }, { "g c": "go-comments-index" }, { "g f": "go-forum-index" }, { "g P": "go-pools-index" }, { "g p": "go-post-index" }, { "g w": "go-wiki-index" }, { "1": "switch-to-tag-script" }, { "2": "switch-to-tag-script" }, { "3": "switch-to-tag-script" }, { "4": "switch-to-tag-script" }, { "5": "switch-to-tag-script" }, { "6": "switch-to-tag-script" }, { "7": "switch-to-tag-script" }, { "8": "switch-to-tag-script" }, { "9": "switch-to-tag-script" }, { "shift+a": "apply-tag-script" }, { "ctrl+a": "select-all" }, { "ctrl+i": "invert-selection" }, { "`": "set-preview-mode" }, { "~": "set-preview-mode" }, { "up": "move-cursor-up" }, { "right": "move-cursor-right" }, { "down": "move-cursor-down" }, { "left": "move-cursor-left" }, { "shift+up": "move-cursor-up" }, { "shift+right": "move-cursor-right" }, { "shift+down": "move-cursor-down" }, { "shift+left": "move-cursor-left" }, { "return": "cursor-open" }, { "ctrl+return": "cursor-open-in-new-tab" }, { "space": "cursor-toggle-selected" }, { "clear": "cursor-toggle-selected" }, // Numpad 5 { "del": "apply-tag-script" }, // Numpad Period { "*": "select-all" }, // Numpad Multiply { "f": "cursor-favorite" }, ]); // XXX don't hardcode these Mousetrap.bindGlobal("esc", Keys.escape); Mousetrap.bindGlobal("ctrl+return", Keys.submitForm); // XXX figure out how to unbind W/S properly. //$(document).unbind("keydown", "w s"); //Mousetrap.bind("w", Keys.scroll(+1, 50, 0.06)); //Mousetrap.bind("s", Keys.scroll(-1, 50, 0.06)); // Danbooru.Shortcuts.nav_scroll_down = Navigation.scroll(+1, 50, 0.06); // Danbooru.Shortcuts.nav_scroll_up = Navigation.scroll(-1, 50, 0.06); } /* Actions */ static escape(event) { const $target = $(event.target); if ($target.is("input, textarea")) { $target.blur(); } else { $('#close-notice-link').click(); // XXX only do if no notice and not already in view mode. ModeMenu.setMode("view"); } // Allow event to bubble up so that escape still closes jquery UI dialogs. return true; } static submitForm(event) { const $target = $(event.target); if ($target.is("#ex-tags:focus")) { Header.executeSearchInNewTab(); } else if ($target.is("input, textarea")) { $target.closest("form").find('input[type="submit"][value="Submit"]').click(); } return false; } } class Notes { static initialize() { $(Notes.initializeLivePreview); } static initializeLivePreview() { Danbooru.Note.Edit.show = _.wrap(Danbooru.Note.Edit.show, (show, ...args) => { show(...args); $(".note-edit-dialog textarea").off("input").on("input", _.throttle(Notes.updatePreview, 32)); }); } static updatePreview(event) { const $textarea = $(event.target); const note_id = $textarea.closest(".ui-dialog-content").data("id"); const $note_body = Danbooru.Note.Body.find(note_id); const $note_box = Danbooru.Note.Box.find(note_id); Danbooru.Note.Body.set_text($note_body, $note_box, $textarea.val()); Danbooru.Note.Body.show(note_id); } } class PostPreviews { // Show post previews when hovering over post #1234 links. static initializePostLinkPreviews() { const posts = $('a[href^="/posts/"]').filter((i, e) => /post #\d+/.test($(e).text())); PostPreviews.initialize(posts); } // Show post previews when hovering over thumbnails. static initializeThumbnailPreviews() { // The thumbnail container is .post-preview on every page but comments and // the mod queue. Handle those specially. const posts = ` .post-preview:not(.ex-no-tooltip) > a > img, #c-comments .post-preview > .preview > a > img, #c-post-moderator-queues .mod-queue-preview aside img `; PostPreviews.initialize(posts); } static initialize(selector) { $(document).on('mouseover', selector, event => { const delay = EX.config.thumbnailPreviewDelay; const [, postID] = $(event.target).closest("a").attr("href").match(/\/posts\/(\d+)/); $(event.target).qtip({ content: { text: (event, api) => { Post.get(postID).then(post => { User.get(post.uploader_id).then(uploader => { api.set("content.text", Posts.renderExcerpt(post, uploader)); api.reposition(event, false); }); }); return "Loading..."; } }, events: { show: (event) => { if (PreviewPanel.opened()) { event.preventDefault(); } } }, overwrite: false, style: { classes: "qtip-bootstrap", tip: { corner: false, } }, show: { delay: delay, solo: true, event: event.type, ready: true }, hide: { delay: 100, fixed: true, }, position: { my: "top left", at: "top right", viewport: $("#ex-viewport"), effect: false, adjust: { method: "flipinvert shift", resize: false, scroll: false, x: 10, } } }, event); }); } } class Sidebar { static initialize() { let $sidebar = Sidebar.$panel; if ($sidebar.length === 0) { return; } $sidebar.parent().addClass("ex-panel-container"); $sidebar.addClass("ex-panel"); const width = _.defaultTo(EX.config.sidebarState[EX.config.pageKey()], EX.config.defaultSidebarWidth); Sidebar.width = width; $sidebar.after(` <div id="ex-sidebar-resizer" class="ex-vertical-resizer"></div> `); $("#ex-sidebar-resizer").draggable({ axis: "x", helper: "clone", drag: _.throttle(Sidebar.resize, 16), stop: _.debounce(Sidebar.save, 100), }); } static resize(event, ui) { const width = Math.max(0, ui.position.left - Sidebar.$panel.position().left); Sidebar.width = width; } static open() { if (Sidebar.width === 0) { Sidebar.width = EX.config.defaultSidebarWidth; } Sidebar.$panel.show({ effect: "slide", direction: "right" }).promise().then(Sidebar.save); } static close() { Sidebar.$panel.hide({ effect: "slide", direction: "left" }).promise().then(Sidebar.save); } static save() { let state = EX.config.sidebarState; state[EX.config.pageKey()] = Math.max(0, Sidebar.width); EX.config.sidebarState = state; } static get $panel() { return $("#sidebar"); } static get width() { return Sidebar.$panel.width(); } static set width(width) { Sidebar.$panel.width(width); Sidebar.$panel.toggle(Sidebar.$panel.width() > 0); } } var Artist = Resource.Artist = class Artist extends Resource { static get primaryKey() { return "id"; } }; class Artists { static initialize() { if ($("#c-artists #a-index").length) { Artists.replaceIndex(); } } static replaceIndex() { let $table = $("#c-artists #a-index > table:nth-child(2)"); let artists = _($table.find("> tbody > tr")).map(e => ({ id: $(e).attr("id").match(/artist-(\d+)/)[1], name: $(e).find("> td:nth-child(1) > a:nth-child(1)").text() })); let requests = [ Artist.search(artists.map("id"), { order: UI.query("search[order]") }), Tag.search(artists.map("name"), { hide_empty: "no" }), Artist.index({ search: { is_active: true, order: "created_at" }, limit: 8 }), Artist.index({ search: { is_active: true, order: "updated_at" }, limit: 8 }), Artist.index({ search: { is_active: false, order: "updated_at" }, limit: 8 }), ]; Promise.all(requests).then(([artists, tags, created, updated, deleted]) => { artists = artists.map(artist => _.merge(artist, { tag: _(tags).find(["name", artist.name]) }) ); let $paginator = $(".paginator"); const index = Artists.renderIndex(artists, created, updated, deleted); $("#c-artists #a-index").addClass("ex-index").html(index); $paginator.appendTo("#content"); }); } static renderIndex(artists, created, updated, deleted) { return ` <aside id="sidebar"> ${Artists.renderSidebar(created, updated, deleted)} </aside> <section id="content"> ${Artists.renderTable(artists)} </section> `; } static renderSidebar(created, updated, deleted) { return ` <section class="ex-artists-search"> ${Artists.renderSearchForm()} </section> <section class="ex-artists-recent-changes"> ${Artists.renderRecentChanges(created, updated, deleted)} </section> `; } static renderSearchForm() { return ` <h1>Search</h1> <form class="simple_form" action="/artists" accept-charset="UTF-8" method="get"> <input name="utf8" type="hidden" value="✓"> <label for="search_name">Name</label> <input type="text" name="search[name]" id="search_name" class="ui-autocomplete-input" autocomplete="off" placeholder="Search artist name or URL"> <label for="search_order">Order</label> <select name="search[order]" id="search_order"> <option value="created_at">Recently created</option> <option value="updated_at">Last updated</option> <option value="name">Name</option> </select> <input type="submit" name="commit" value="Search"> </form> `; } static renderRecentChanges(created, updated, deleted) { function renderArtistsList(artists, heading, params) { return ` <section class="ex-artists-list"> <div class="ex-artists-list-heading"> <h2>${heading}</h2> <span> (${UI.linkTo("more", "/artists", { search: params })}) </span> </div> <ul> ${renderUl(artists)} </ul> </section> `; } function renderUl(artists) { return _(artists).map(artist => ` <li class="category-1"> ${UI.linkTo(artist.name, `/artists/${artist.id}`)} <time class="ex-short-relative-time" datetime="${artist.updated_at}" title="${moment(artist.updated_at).format()}"> ${moment(artist.updated_at).locale("en-short").fromNow()} </time> </li> `).join(""); } return ` <h1>Recent Changes</h1> ${renderArtistsList(created, "New Artists", { is_active: true, order: "created_at" })} ${renderArtistsList(updated, "Updated Artists", { is_active: true, order: "updated_at" })} ${renderArtistsList(deleted, "Deleted Artists", { is_active: false, order: "updated_at" })} `; } static renderTable(artists) { return ` <table class="ex-artists striped" width="100%"> <thead> <tr> <th class="ex-artist-id">ID</th> <th class="ex-artist-name">Name</th> <th class="ex-artist-post-count">Posts</th> <th class="ex-artist-other-names">Other Names</th> <th class="ex-artist-group-name">Group</th> <th class="ex-artist-status">Status</th> <th class="ex-artist-created">Created</th> <th class="ex-artist-updated">Updated</th> </tr> </thead> <tbody> ${artists.map(Artists.renderRow).join("")} </tbody> </table> `; } static renderRow(artist) { const otherNames = (artist.other_names || "") .split(/\s+/) .sort() .map(name => UI.linkTo(name, "/artists", { search: { name: name }}, "ex-artist-other-name") ) .join(", "); const groupLink = UI.linkTo( artist.group_name, "/artists", { search: { name: `group:${artist.group_name}` }}, "ex-artist-group-name" ); return ` <tr class="ex-artist"> <td class="ex-artist-id"> ${UI.linkTo(`artist #${artist.id}`, `/artists/${artist.id}`)} </td> <td class="ex-artist-name category-${artist.tag.category}"> ${UI.linkTo("?", "/wiki_pages", { title: artist.name }, "wiki-link")} ${UI.linkTo(artist.name, `/artists/${artist.id}`, {}, "artist-link")} </td> <td class="ex-artist-post-count"> ${UI.linkTo(artist.tag.post_count, "/posts", { tags: artist.name }, "search-tag")} </td> <td class="ex-artist-other-names"> ${otherNames} </td> <td class="ex-artist-group-name"> ${artist.group_name ? groupLink : ""} </td> <td class="ex-artist-status"> ${artist.is_banned ? "Banned" : ""} ${artist.is_active ? "" : "Deleted"} </td> <td class="ex-artist-created"> ${moment(artist.created_at).fromNow()} </td> <td class="ex-artist-updated"> ${moment(artist.updated_at).fromNow()} </td> </tr> `; } } class Comments { static initialize() { if ($("#c-comments").length || $("#c-posts #a-show").length) { $(function () { Comments.initializePatches(); Comments.initializeMetadata($(".comments-for-post")); }); } } static initializePatches() { $(window).on("danbooru:index_for_post", (event, post_id) => { const $parent = $(`.comments-for-post[data-post-id=${post_id}]`); Comments.initializeMetadata($parent); }); } /* * Add 'comment #1234' permalink. * Add comment scores. */ static initializeMetadata($parent) { $parent.find('.comment').each((i, e) => { const $menu = $(e).find('menu'); const post_id = $(e).data('post-id'); const comment_id = $(e).data('comment-id'); const comment_score = $(e).data('score'); if ($menu.children().length > 0) { $menu.append($('<li> | </li>')); } $menu.append($(` <li> <a href="/posts/${post_id}#comment-${comment_id}">Comment #${comment_id}</a> </li> `)); $menu.append($(` <span class="info"> <strong>Score</strong> <span>${comment_score}</span> </span> `)); }); } } class PostVersions { static initialize() { if ($("#c-post-versions #a-index").length && !UI.query("search[post_id]")) { PostVersions.initializeThumbnails(); } } // Show thumbnails instead of post IDs. static initializeThumbnails() { let $post_column = $('tr td:nth-child(1)'); let post_ids = $.map($post_column, e => $(e).text().match(/(\d+).\d+/)[1] ); let post_data = []; let requests = _.chunk(post_ids, 100).map(function (ids) { let search = 'id:' + ids.join(','); return $.get(`/posts.json?tags=${search}`).then(data => { data.forEach(post => post_data[post.id] = post); }); }); Promise.all(requests).then(() => { $post_column.each((i, e) => { let post_id = $(e).text().match(/(\d+).\d+/)[1]; $(e).html(Posts.preview(post_data[post_id])); }); }); } } var PostCount = Resource.PostCount = class PostCount extends Resource { static get primaryKey() { return "id"; } static get controller() { return "/counts/posts"; } static count(query) { return PostCount.index({ tags: query }).then(response => response.counts.posts); } }; class SavedSearches { static initialize() { if ($("#c-saved-searches #a-index").length === 0) { return; } $("thead tr").replaceWith($(` <tr> <th id="ss-query" data-sort="string" data-sort-multicolumn="1,2,3"> Query </th> <th id="ss-labels" data-sort="string" data-sort-multicolumn="2,1,3"> Labels </th> <th id="ss-latest-post" data-sort="int" data-sort-multicolumn="2,3,1" data-sort-default="desc"> Latest Post <th></th> </tr> `)); $("tbody tr").each((i, row) => { $(`<td class="ss-latest-post"></td>`).insertBefore($(row).find(".links")); }); $("tbody tr").each((i, row) => { const $search = $(row).find("td:first-child"); const tags = $search.text(); PostCount.count(tags).then(count => { $search.append(`<span class="post-count">${count}</span>`); }); Post.index({ tags: tags, limit: 1 }).then(posts => { const post = _.first(posts); const post_link = (post === undefined) ? "<em>none</em>" : `<td data-sort-value="${post.id}"><a href="/posts/${post.id}">post #${post.id}</a>`; $(row).find(".ss-latest-post").replaceWith($(post_link)); }); }); } } class Users { static get QTIP_SETTINGS() { return { overwrite: false, style: { classes: "qtip-bootstrap", tip: { corner: false }, }, show: { solo: true, ready: true }, hide: { delay: 100, fixed: true, }, position: { my: "top left", at: "top right", effect: false, adjust: { method: "flipinvert shift", resize: false, scroll: false, x: 10, } } }; } static initialize() { this.initializeWordBreaks(); if ($("#c-users #a-show").length) { this.initializeCollapsibleHeaders(); this.initializeExpandableGalleries(); } } // Wordbreak long usernames (e.g. GiantCaveMushroom) by inserting // wordbreaks at lowercase -> non-lowercase transitions. static initializeWordBreaks() { this.userLinks().html((i, name) => name.replace(/([a-z])(?=[^a-z])/g, c => c + "<wbr>") ); } // Add tooltips to usernames. Also add data attributes for custom CSS styling. static initializeUserTooltips() { // XXX triggers on Profile / Settings links on /static/site_map $(document).on("mouseover", '#page a[href^="/users/"]', e => { const $user = $(e.target); const userId = Users.parseUserId($user); if (userId === null) { return; } const qtipParams = _.merge(Users.QTIP_SETTINGS, { show: { event: e.type }, position: { viewport: $("#ex-viewport") }, content: { text: (event, api) => { User.get(userId).then(user => { api.set("content.text", Users.renderExcerpt(user)); api.reposition(event, false); }); return "Loading..."; }, } }); $user.qtip(qtipParams); }); } static initializeCollapsibleHeaders () { $("#c-users #a-show > .box").each((i, e) => { const $gallery = $(e); // Make gallery headers collapsible. const $toggleCollapse = $(`<a class="ui-icon ui-icon-triangle-1-s collapsible-header"></a>`); $gallery.find("h2").prepend($toggleCollapse); $toggleCollapse.click(event => { $(event.target).closest("h2").next("div").slideToggle(); $(event.target).toggleClass('ui-icon-triangle-1-e ui-icon-triangle-1-s'); return false; }); }); } static initializeExpandableGalleries() { const user = $("#a-show > h1 > a").text().replace(/[\u200B-\u200D\uFEFF]/g, '').replace(" ", "_"); // Rewrite /favorites link into ordfav: search so it's consistent with other post sections. $(".box a[href^='/favorites?user_id=']").attr( "href", `/posts?tags=ordfav:${encodeURIComponent(user)}` ); $("#c-users #a-show > .box").each((i, e) => { const $gallery = $(e).addClass("ex-post-gallery"); // Store the tag search corresponding to this gallery section in a data // attribute for the click handler. const [, tags] = $gallery.find('h2 a[href^="/posts"]').attr("href").match(/\/posts\?tags=(.*)/); $gallery.attr("data-tags", decodeURIComponent(tags)); $gallery.find("> div").append(` <article class="ex-text-thumbnail"> <a href="#">More »</a> </article> `); $gallery.find(".ex-text-thumbnail a").click(event => { const $gallery = $(event.target).closest(".ex-post-gallery"); const limit = 30; const page = Math.trunc($gallery.find(".post-preview").children().length / limit) + 1; Post.index({ tags: $gallery.data("tags"), page, limit }).then(posts => { const html = posts.map(Posts.preview).join(""); // Hide the original posts to avoid appending duplicate posts. $gallery.find("> div .post-preview:not(.ex-post-preview)").hide(); // Append new posts, moving the "More »" link to the end. const $more = $gallery.find(".ex-text-thumbnail").detach(); $gallery.find("> div").append(html, $more); $gallery.find(".ex-post-preview").trigger("ex.post-preview:create"); }); return false; }); }); } static renderExcerpt(user) { return ` <section class="ex-excerpt ex-user-excerpt"> <div class="ex-excerpt-title ex-user-excerpt-title"> <span class="user-info">${User.render(user)}</span> </div> <div class="ex-excerpt-body ex-user-excerpt-body"> <dl class="info"> <dt>Joined</dt> <dd>${moment(user.created_at).fromNow()}</dd> </dl> <dl class="info"> <dt>Uploads</dt> <dd>${user.post_upload_count}</dd> </dl> <dl class="info"> <dt>Edits</dt> <dd>${user.post_update_count}</dd> </dl> <dl class="info"> <dt>Notes</dt> <dd>${user.note_update_count}</dd> </dl> <dl class="info"> <dt>Comments</dt> <dd>${user.comment_count}</dd> </dl> <dl class="info"> <dt>Forum Posts</dt> <dd>${user.forum_post_count}</dd> </dl> </div> </section> `; } static userLinks() { return $('#page a[href^="/users/"]').filter((i, e) => this.parseUserId($(e))); } static parseUserId($user) { return _.nth($user.attr("href").match(/^\/users\/(\d+)$/), 1); } } class WikiPages { static initialize() { if ($("#c-wiki-pages").length === 0) { return; } WikiPages.initializeCollapsibleHeadings(); WikiPages.initializeTableOfContents(); } // Add collapse/expand button to headings. static initializeCollapsibleHeadings() { const $headings = $("#wiki-page-body :header"); if ($headings.length < 3) { return; } $headings.prepend($('<a class="ui-icon ui-icon-triangle-1-s collapsible-header"></a>')); $headings.find("a.collapsible-header").click(e => { const $button = $(e.target); // Collapse everything up to the next heading at the same // level, or up to the alias/implication list at the bottom of the page. $button.toggleClass('ui-icon-triangle-1-e ui-icon-triangle-1-s'); $button.parent('h1').nextUntil('p.hint, h1').slideToggle(); $button.parent('h2').nextUntil('p.hint, h1, h2').slideToggle(); $button.parent('h3').nextUntil('p.hint, h1, h2, h3').slideToggle(); $button.parent('h4').nextUntil('p.hint, h1, h2, h3, h4').slideToggle(); $button.parent('h5').nextUntil('p.hint, h1, h2, h3, h4, h5').slideToggle(); $button.parent('h6').nextUntil('p.hint, h1, h2, h3, h4, h5, h6').slideToggle(); }); } // Add Table of Contents expandable. static initializeTableOfContents() { const $headings = $("#wiki-page-body :header"); const hasToC = $("div.expandable-header > span") .filter((i, e) => $(e).text().match(/table of contents/i)) .length > 0; if ($headings.length < 3 || hasToC) { return; } const $toc = DText.createExpandable( 'Table of Contents', '<p class="tn">This table of contents was autogenerated by Danbooru EX.</p> <ul></ul>' ).prependTo('#wiki-page-body'); // Build ToC. Create a nested heirarchy matching the hierarchy of // headings on the page; an h5 following an h4 opens a new submenu, // another h4 closes the submenu. Likewise for h5, h6, etc. let $submenu = null; let $menu = $toc.find('ul'); let level = $headings.length > 0 ? parseInt($headings.first().get(0).tagName[1]) : undefined; $headings.each((i, e) => { const header = $(e).text(); const anchor = 'dtext-' + header.toLowerCase() .replace(/[^a-z]+/g, '-') .replace(/^-|-$/, ''); const nextLevel = parseInt(e.tagName[1]); if (nextLevel > level) { $submenu = $('<ul></ul>'); $menu.append($submenu); $menu = $submenu; } else if (nextLevel < level) { $menu = $menu.parent(); } $(e).attr('id', anchor); $menu.append($(`<li><a href="#${anchor}">${header}</a></li>`)); level = nextLevel; }); } } class UI { static initialize() { UI.initializeFooter(); UI.initializeMoment(); EX.config.styleWikiLinks && UI.initializeWikiLinks(); EX.config.useRelativeTimestamps && UI.initializeRelativeTimes(); const $viewport = $('<div id="ex-viewport"></div>'); $("body").append($viewport); } // Use relative times everywhere. static initializeRelativeTimes() { const ABS_DATE = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}/; const absDates = $('time').filter((i, e) => $(e).text().match(ABS_DATE)); absDates.each((i, e) => { const timeAgo = moment($(e).attr('datetime')).fromNow(); $(e).text(timeAgo); }); } static initializeFooter() { $("footer").append( `| Danbooru EX <a href="https://github.com/evazion/danbooru-ex">v${GM_info.script.version}</a> – <a href="/users/${$("body").attr('data-current-user-id')}/edit#ex-settings">Settings</a>` ); } static initializeMoment() { moment.locale("en-short", { relativeTime : { future: "in %s", past: "%s ago", s: "1 second", ss: "%d seconds", m: "1 minute", mm: "%d minutes", h: "1 hour", hh: "%d hours", d: "1 day", dd: "%d days", M: "1 month", MM: "%d months", y: "1 year", yy: "%d years" } }); moment.locale("en"); moment.defaultFormat = "MMMM Do YYYY, h:mm a"; } // Color code tags linking to wiki pages. Also add a tooltip showing the tag // creation date and post count. static initializeWikiLinks() { const parseTagName = wikiLink => decodeURIComponent($(wikiLink).attr('href').match(/\?title=(.*)$/)[1]); const metaWikis = /^(about:|disclaimer:|help:|howto:|list_of|pool_group:|tag_group:|template:)/i; const $wikiLinks = $(".dtext-wiki-link"); const tags = _($wikiLinks.toArray()) .map(parseTagName) .reject(tag => tag.match(metaWikis)) .value(); // Fetch tag data for each batch of tags, then categorize them and add tooltips. Tag.search(tags).then(tags => { tags = _.keyBy(tags, "name"); $wikiLinks.each((i, e) => { const $wikiLink = $(e); const name = parseTagName($wikiLink); const tag = tags[name]; if (name.match(metaWikis)) { return; } else if (tag === undefined) { $wikiLink.addClass('tag-dne'); return; } const tagCreatedAt = moment(tag.created_at).format('MMMM Do YYYY, h:mm:ss a'); const tagTitle = `${Tag.Categories[tag.category]} tag #${tag.id} - ${tag.post_count} posts - created on ${tagCreatedAt}`; $wikiLink.addClass(`tag-type-${tag.category}`).attr('title', tagTitle); if (tag.post_count === 0) { $wikiLink.addClass("tag-post-count-empty"); } else if (tag.post_count < 1000) { $wikiLink.addClass("tag-post-count-small"); } }); }); } static linkTo(name, path = "/", params = {}, ...classes) { const query = $.param(params); const href = (query === "") ? path : path + "?" + query; return `<a class="${_.escape(classes.join(" "))}" href="${href}">${_.escape(name)}</a>`; } static query(param) { return new URL(window.location).searchParams.get(param); } } UI.Header = Header; UI.ModeMenu = ModeMenu; UI.Notes = Notes; UI.PostPreviews = PostPreviews; UI.PreviewPanel = PreviewPanel; UI.Sidebar = Sidebar; UI.Artists = Artists; UI.Comments = Comments; UI.Posts = Posts; UI.PostVersions = PostVersions; UI.SavedSearches = SavedSearches; UI.Users = Users; UI.WikiPages = WikiPages; ___$insertStyle("#ex-header {\n position: absolute;\n top: 0;\n padding: 5px 0;\n width: 100%;\n z-index: 100;\n background: #1e1e2c;\n border-bottom: 1px solid #2c2d3f;\n max-heigt: 32px; }\n #ex-header .ex-header-wrapper {\n display: flex; max-height:32px; }\n #ex-header .ex-header-wrapper.ex-fixed.ex-header-scrolled {\n border-bottom: 1px solid #EEEEEE;\n box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.1); }\n #ex-header .ex-header-wrapper h1 {\n display: inline-block;\n font-size: 2.5em;\n margin: 0 30px; }\n #ex-header .ex-header-wrapper .ex-search-box {\n margin: auto;\n display: flex;\n flex: 0 1 30%; }\n #ex-header .ex-header-wrapper .ex-search-box input#ex-tags {\n flex: 0 1 100%; }\n #ex-header .ex-header-wrapper .ex-search-box input[type=\"submit\"] {\n flex: 1;\n margin: auto 1em; }\n #ex-header .ex-header-wrapper .ex-mode-menu {\n margin: auto;\n flex: 1 2 70%; }\n #ex-header .ex-header-wrapper .ex-mode-menu .ex-tag-script-controls {\n display: inline-block;\n margin: auto; }\n #ex-header .ex-header-wrapper .ex-mode-menu label {\n font-weight: bold;\n cursor: auto; }\n @media (max-width: 1280px) {\n #ex-header .ex-header-wrapper .ex-mode-menu label {\n display: none; } }\n #ex-header .ex-header-wrapper .ex-header-close {\n margin: auto;\n margin-right: 30px;\n color: #0073ff;\n cursor: pointer; }\n\n@media (max-width: 1280px) {\n header h1 {\n font-size: 1.5em !important; } }\n\nh1.ex-small-header {\n font-size: 1.5em !important; }\n\n/* Fix sidebar search box from rendering on top of EX header bar. */\n#page #sidebar input[type=\"text\"] {\n z-index: 10 !important; }\n\n/* /posts/1234 */\n/* Move artist tags to top of the tag list. */\n#tag-list {\n /*\n * Break tags that are too long for the tag list (e.g.\n * kuouzumiaiginsusutakeizumonokamimeichoujin_mika)\n */\n word-break: break-word; }\n #tag-list .ex-tag-list-header h1, #tag-list .ex-tag-list-header h2 {\n display: inline-block; }\n #tag-list .ex-tag-list-header .post-count {\n margin-left: 0.5em; }\n\n/*\n * Make the parent/child thumbnail container scroll vertically, not horizontally, to prevent\n * long child lists from blowing out the page width.\n */\n#has-parent-relationship-preview,\n#has-children-relationship-preview {\n overflow: auto;\n white-space: initial; }\n\n#c-posts #a-show {\n /*\n #image-container {\n position: relative;\n display: inline-block;\n\n .desc {\n display: none;\n }\n }\n\n #note-container {\n position: initial;\n\n .note-box {\n background: hsla(60,100%,97%,0.5);\n outline: 1px solid white;\n border: 1px solid black;\n box-sizing: border-box;\n\n .note-box-inner-border {\n display: none;\n }\n }\n }\n*/ }\n\n.ex-fit-width {\n max-width: 100%;\n height: auto !important; }\n\n.ex-post-gallery span h2 {\n display: inline-block; }\n\n.ex-text-thumbnail {\n display: inline-block;\n float: left;\n height: 154px;\n width: 154px;\n margin: 0 10px 10px 0;\n text-align: center;\n background: #EEEEEE;\n border: 2px solid #DDDDDD; }\n .ex-text-thumbnail a {\n display: inline-block;\n width: 100%;\n height: 100%;\n line-height: 154px; }\n\n.ex-panel-container {\n display: flex;\n min-height: 100vh; }\n .ex-panel-container .ex-panel {\n flex: 0 0 auto;\n overflow: auto;\n align-self: start; }\n .ex-panel-container .ex-content-panel {\n flex: 1 1;\n margin-left: 0px !important; }\n .ex-panel-container #ex-preview-panel {\n position: sticky;\n top: 3em;\n max-height: calc(100vh - 127px);\n overflow-y: auto;\n overflow-x: hidden;\n overscroll-behavior-y: contain; }\n .ex-panel-container #ex-preview-panel::-webkit-scrollbar {\n width: 5px;\n height: 5px; }\n .ex-panel-container #ex-preview-panel::-webkit-scrollbar-button {\n width: 0px;\n height: 0px; }\n .ex-panel-container #ex-preview-panel::-webkit-scrollbar-thumb {\n background: #999999;\n border: 0px none #FFFFFF;\n border-radius: 0px; }\n .ex-panel-container #ex-preview-panel::-webkit-scrollbar-thumb:hover {\n background: #AAAAAA; }\n .ex-panel-container #ex-preview-panel::-webkit-scrollbar-thumb:active {\n background: #AAAAAA; }\n .ex-panel-container #ex-preview-panel::-webkit-scrollbar-track {\n background: #EEEEEE;\n border: 0px none #ffffff;\n border-radius: 0px; }\n .ex-panel-container #ex-preview-panel::-webkit-scrollbar-track:hover {\n background: #EEEEEE; }\n .ex-panel-container #ex-preview-panel::-webkit-scrollbar-track:active {\n background: #EEEEEE; }\n .ex-panel-container #ex-preview-panel::-webkit-scrollbar-corner {\n background: transparent; }\n .ex-panel-container #ex-preview-panel .ex-no-image-selected {\n text-align: center;\n margin-top: 2em; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post {\n font-size: 0.85714em;\n line-height: 1.2em;\n margin: 0 1em; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata {\n max-height: 9.95em;\n margin-bottom: 1em;\n overflow-y: auto; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar {\n width: 5px;\n height: 5px; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar-button {\n width: 0px;\n height: 0px; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar-thumb {\n background: #999999;\n border: 0px none #FFFFFF;\n border-radius: 0px; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar-thumb:hover {\n background: #AAAAAA; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar-thumb:active {\n background: #AAAAAA; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar-track {\n background: #EEEEEE;\n border: 0px none #ffffff;\n border-radius: 0px; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar-track:hover {\n background: #EEEEEE; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar-track:active {\n background: #EEEEEE; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar-corner {\n background: transparent; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata .ex-preview-panel-post-title .fav-count {\n margin-right: 0.25em; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata .ex-preview-panel-post-title .post-info {\n margin-right: 1em;\n color: #333;\n white-space: nowrap; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata .ex-preview-panel-post-title .post-info h1 {\n color: #000;\n display: inline;\n font-size: 1em;\n margin-right: 0.25em; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-body {\n display: flex;\n flex-direction: column; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-body article.post-preview {\n width: auto;\n height: auto;\n margin: auto;\n justify-content: center; }\n .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-body article.post-preview img {\n object-fit: scale-down;\n max-width: 100%;\n max-height: calc(90vh - 127px); }\n .ex-panel-container .ex-vertical-resizer {\n cursor: col-resize;\n flex: 0 0 1px;\n border: 0.5em solid #2c2d3f;\n background: #2c2d3f;\n transition: background 0.125s; }\n .ex-panel-container .ex-vertical-resizer:hover {\n background: #2c2d3f;\n transition: background 0.125s; }\n\n.ex-tag-list.ex-tag-list-inline {\n word-break: break-all; }\n .ex-tag-list.ex-tag-list-inline h1 {\n display: inline;\n font-size: 1em; }\n .ex-tag-list.ex-tag-list-inline ul {\n display: inline; }\n .ex-tag-list.ex-tag-list-inline ul li {\n display: inline-block;\n margin-right: 0.5rem; }\n\n#ex-viewport {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n visibility: hidden;\n margin: 4em; }\n\n.post-media {\n max-width: 100%;\n max-height: 100%;\n width: auto;\n height: auto;\n box-sizing: border-box;\n object-fit: contain; }\n\n/*\n.qtip {\n max-width: 720px;\n max-height: none;\n border-width: 2px;\n box-sizing: border-box;\n\n .ex-excerpt {\n &-title {\n font-size: 1em;\n margin-bottom: 1em;\n padding-bottom: 1em;\n border-bottom: 1px solid $dim-border-color;\n\n // Baseline align the title so the bottom margin/padding is correct.\n // https://blogs.adobe.com/webplatform/2014/08/13/one-weird-trick-to-baseline-align-text/\n &::first-line {\n line-height: 0px;\n }\n\n &::before {\n content: \"\";\n display: inline-block;\n height: $line-height;\n }\n }\n\n &-body {\n display: flex;\n flex-direction: row;\n }\n }\n\n ::-webkit-scrollbar {\n width: 5px;\n height: 5px;\n }\n ::-webkit-scrollbar-button {\n width: 0px;\n height: 0px;\n }\n ::-webkit-scrollbar-thumb {\n background: #999999;\n border: 0px none #ffffff;\n border-radius: 0px;\n }\n ::-webkit-scrollbar-thumb:hover {\n background: #AAAAAA;\n }\n ::-webkit-scrollbar-thumb:active {\n background: #AAAAAA;\n }\n ::-webkit-scrollbar-track {\n background: $dim-border-color;\n border: 0px none #ffffff;\n border-radius: 0px;\n }\n ::-webkit-scrollbar-track:hover {\n background: $dim-border-color;\n }\n ::-webkit-scrollbar-track:active {\n background: $dim-border-color;\n }\n ::-webkit-scrollbar-corner {\n background: transparent;\n }\n}\n*/\n.post-media {\n max-width: 100%;\n max-height: 100%;\n width: auto;\n height: auto;\n box-sizing: border-box;\n object-fit: contain; }\n\n.qtip .ex-post-excerpt-title .post-info {\n color: #333; }\n .qtip .ex-post-excerpt-title .post-info.id {\n color: #333;\n font-weight: bold; }\n\n.qtip .ex-post-excerpt-body {\n max-height: 350px; }\n\n.qtip .ex-post-excerpt-preview {\n flex: 0 1;\n width: auto;\n height: auto;\n overflow: visible; }\n\n.qtip .ex-post-excerpt-metadata {\n flex: 1 1;\n overflow-y: auto; }\n\n.qtip .ex-user-excerpt-body dl.info {\n margin-right: 2em; }\n\n.ex-fixed {\n position: fixed !important; }\n\n/* Overrides for Danbooru's responsive layout */\n@media screen and (max-width: 660px) {\n body {\n overflow-x: hidden; }\n #ex-header input {\n font-size: 1em; }\n #ex-header {\n text-align: initial;\n line-height: initial; }\n #nav {\n display: block;\n float: none;\n font-size: 1em; }\n header#top menu {\n width: initial; }\n header#top menu li a {\n padding: 6px 5px; }\n .ex-preview-panel-container {\n display: block;\n min-height: initial; }\n #sidebar,\n #ex-sidebar-resizer,\n #ex-preview-panel-resizer,\n #ex-preview-panel {\n display: none !important; } }\n\n#notice {\n top: 4.5em !important; }\n\n.ex-artists {\n white-space: nowrap; }\n\n.ex-artist .ex-artist-id {\n width: 10%; }\n\n.ex-artist .ex-artist-other-names {\n width: 100%;\n white-space: normal; }\n\n#c-artists #sidebar label {\n display: block;\n font-weight: bold;\n padding: 4px 0 4px 0;\n width: auto;\n cursor: auto; }\n\n#c-artists #sidebar input[type=\"text\"] {\n width: 100% !important; }\n\n#c-artists #sidebar button[type=\"submit\"] {\n display: block;\n margin: 4px 0 4px 0; }\n\n#c-artists #sidebar h2 {\n font-size: 1em;\n display: inline-block;\n margin: 0.75em 0 0.25em 0; }\n\n#c-artists #a-index {\n opacity: 0; }\n\n.ex-index {\n opacity: 1 !important;\n transition: opacity 0.15s; }\n\n#c-users #a-edit #ex-settings-section label {\n display: inline-block; }\n\nbody.mode-tag-script {\n background-color: #1e1e2c; }\n\nbody.mode-tag-script #ex-header {\n border-top: 2px solid #D6D;\n padding-top: 3px; }\n\nbody.mode-preview #ex-header {\n border-top: 2px solid #0073ff;\n padding-top: 3px; }\n\nbody.mode-view #ex-preview-panel-resizer {\n display: none; }\n\nbody.mode-tag-script article.post-preview > a, #c-moderator-post-queues .post-preview aside > a, #c-comments .post-preview .preview > a,\nbody.mode-preview article.post-preview > a, #c-moderator-post-queues .post-preview aside > a, #c-comments .post-preview .preview > a {\n width: 100%;\n height: 100%; }\n body.mode-tag-script article.post-preview > a:focus, #c-moderator-post-queues .post-preview aside > a:focus, #c-comments .post-preview .preview > a:focus,\n body.mode-preview article.post-preview > a:focus, #c-moderator-post-queues .post-preview aside > a:focus, #c-comments .post-preview .preview > a:focus {\n outline: none; }\n\nbody.mode-preview article.post-preview:hover, body.mode-preview #c-moderator-post-queues .post-preview aside:hover, body.mode-preview #c-comments .post-preview .preview:hover, body.mode-tag-script article.post-preview:hover, body.mode-tag-script #c-moderator-post-queues .post-preview aside:hover, body.mode-tag-script #c-comments .post-preview .preview:hover {\n opacity: 0.75; }\n\nbody.mode-tag-script article.post-preview.ui-selected, body.mode-tag-script #c-moderator-post-queues .post-preview aside.ui-selected, body.mode-tag-script #c-comments .post-preview .preview.ui-selected {\n background: #66b3cc; }\n body.mode-tag-script article.post-preview.ui-selected img, body.mode-tag-script #c-moderator-post-queues .post-preview aside.ui-selected img, body.mode-tag-script #c-comments .post-preview .preview.ui-selected img {\n opacity: 0.5; }\n\n#posts-container article.post-preview {\n padding: 0 10px 10px 0;\n margin: 0;\n float: left; }\n\n#posts > div {\n padding: 2px; }\n\narticle.post-preview.ex-cursor, #c-moderator-post-queues .post-preview aside.ex-cursor, #c-comments .post-preview .preview.ex-cursor {\n z-index: 50;\n outline: 2px solid #409fbf;\n background-color: #b3d9e6; }\n\n.ui-selectable-helper {\n position: absolute;\n z-index: 100;\n border: 1px dotted black; }\n\n.ui-selectable {\n -ms-touch-action: none;\n touch-action: none; }\n\n.ex-short-relative-time {\n color: #333;\n margin-left: 0.2em; }\n\n.tag-post-count-empty {\n border-bottom: 1px dotted; }\n\n.tag-dne {\n border-bottom: 1px dotted; }\n\n/* Ensure colorized tags are still hidden. */\n.spoiler:hover a.tag-type-1 {\n color: #A00; }\n\n.spoiler:hover a.tag-type-3 {\n color: #A0A; }\n\n.spoiler:hover a.tag-type-4 {\n color: #0A0; }\n\n.spoiler:not(:hover) a {\n color: black !important; }\n\n.paginator menu li {\n line-height: 2.5em;\n display: inline-block; }\n\na.collapsible-header {\n display: none;\n cursor: pointer;\n margin-left: -16px; }\n\nh1:hover a.collapsible-header, h2:hover a.collapsible-header, h3:hover a.collapsible-header,\nh4:hover a.collapsible-header, h5:hover a.collapsible-header, h6:hover a.collapsible-header {\n display: inline-block !important; }\n\n#wiki-page-body h1, #wiki-page-body h2, #wiki-page-body h3,\n#wiki-page-body h4, #wiki-page-body h5, #wiki-page-body h6 {\n padding-left: 16px;\n margin-left: -16px; }\n"); var EX = window.EX = class EX { static get Config() { return Config; } static get DText() { return DText; } static get Keys() { return Keys; } static get Resource() { return Resource; } static get UI() { return UI; } static get logLevel() { return 1; } static initialize() { // console.timeEnd("preinit"); // console.groupCollapsed("settings"); EX.version = GM_info.script.version; EX.config = new EX.Config(); EX.keys = new EX.Keys(); if (EX.config.enableHotkeys) { EX.keys.initialize(); } EX.config.enableHeader && UI.Header.initialize(); EX.config.resizeableSidebars && UI.Sidebar.initialize(); EX.config.showThumbnailPreviews && UI.PostPreviews.initializeThumbnailPreviews(); // EX.config.showPostLinkPreviews && UI.PostPreviews.initializePostLinkPreviews(); EX.UI.initialize(); EX.config.enableNotesLivePreview && EX.UI.Notes.initialize(); EX.config.usernameTooltips && EX.UI.Users.initializeUserTooltips(); EX.config.enableLargeThumbnails && EX.UI.Posts.initializeLargeThumbnails(); EX.config.artistsRedesign && EX.UI.Artists.initialize(); EX.config.commentsRedesign && EX.UI.Comments.initialize(); EX.config.postsRedesign && EX.UI.Posts.initialize(); EX.config.postVersionsRedesign && EX.UI.PostVersions.initialize(); EX.config.wikiRedesign && EX.UI.WikiPages.initialize(); EX.config.usersRedesign && EX.UI.Users.initialize(); // EX.UI.SavedSearches.initialize(); // console.groupEnd("settings"); // console.timeEnd("initialized"); } static debug(...params) { if (EX.logLevel === 0) { console.log(...params); } } }; window.EX.debug("Danbooru:", window.Danbooru); // console.timeEnd("loaded"); $(function () { try { window.EX.initialize(); } catch(e) { console.trace(e); $("footer").append(`<div class="ex-error">Danbooru EX error: ${e}</div>`); throw e; } }); return EX; }(_,Mousetrap,moment,ResizeSensor));