Greasy Fork

Greasy Fork is available in English.

Fimfiction Comment Tweaks

Tweaks for Fimfiction comments

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name           Fimfiction Comment Tweaks
// @description    Tweaks for Fimfiction comments
// @author         Pluie
// @version        0.2.2
// @license        MIT
// @homepageURL    https://github.com/PluieElectrique/fimfic-comment-tweaks
// @supportURL     https://github.com/PluieElectrique/fimfic-comment-tweaks/issues
// @match          *://www.fimfiction.net/*
// @grant          none
// @run-at         document-idle
// @noframes
// @namespace http://greasyfork.icu/users/307405
// ==/UserScript==

// Note about mobile: To be consistent with Fimfiction, this script detects mobile by using
// `is_mobile`, a global declared in an inline script in <head>. It seems detection of mobile
// browsers is done server side (probably through user agent).

"use strict";

let commentController;
let comment_list;

const QUOTE_LINK_HOVER_DELAY = 85;
const ctCSS = `
.ct--collapse-button { padding: 3px; }
.ct--collapsed-comment .author > .avatar { display: none; }
.ct--collapsed-comment .comment_callbacks > a { opacity: 0.7; }
.ct--collapsed-comment .comment_callbacks > div { display: none; }
.ct--collapsed-comment .comment_data { display: none; }
.ct--collapsed-comment .comment_information:after { height: 0; }
.ct--deleted-link { text-decoration: line-through; }
.ct--expanded-link { opacity: 0.7; }
.ct--forward-hidden { display: none; }
.ct--parent-link-highlight { text-decoration: underline; }
@media all and (min-width: 701px) {
  .comment .data, .comment_information > .buttons { padding-right: 0.3rem; }
  .inline-quote .meta > .name { display: inline; }
}
.embed-container .placeholder.hidden { display: none; }
`;

// A wrapper object that will be assigned onto the real comment controller
const commentControllerShell = {
    // Map from comment ID to { author: string; index?: number; deleted?: boolean }
    commentMetadata: {},

    /* Methods that shadow existing methods */

    getComment(id) {
        let comment = document.getElementById("comment_" + id);
        let promise;
        if (comment === null) {
            promise = CommentListController.prototype.getComment.call(this, id);
        } else {
            promise = Promise.resolve(comment);
        }

        // We always rewrite the comment in case there's new metadata that we didn't have before.
        return promise.then(comment => {
            let link = comment.querySelector(`a[href='#comment/${id}']`);
            // An equivalent way of checking if a comment is deleted
            if (link !== null) {
                let meta = this.commentMetadata[id];
                if (meta === undefined) {
                    // Remove "#" to avoid confusing comment IDs with comment indexes
                    link.textContent = link.textContent.replace("#", "");
                } else {
                    // Rewrite comment index
                    link.textContent = formatCommentIndex(meta.index);
                }
                this.rewriteQuoteLinks(comment);
            }
            return comment;
        });
    },

    setupQuotes() {
        CommentListController.prototype.setupQuotes.call(this);
        this.storeComments();
        this.rewriteQuoteLinks(this.comment_list);
        setupCollapseButtons();
    },

    goToPage(num) {
        this.storeComments();
        return CommentListController.prototype.goToPage.call(this, num).then(_ => {
            if (is_mobile) {
                let numComments = document.querySelector(".num-comments").textContent;
                // There's a space before "Comments" for consistency with the Fimfiction HTML
                document.querySelector(
                    ".comments-header > .fa-comments"
                ).nextSibling.nodeValue = ` Comments ( ${numComments} )`;
            }
        });
    },

    beginShowQuote(quoteLink) {
        // Just in case a mouseover event is triggered before the last mouseover's mouseout has
        this.endShowQuote();

        let cancel = false;
        this.getComment(quoteLink.dataset.comment_id).then(comment => {
            if (cancel) {
                return;
            }

            let parent = fQuery.closestParent(quoteLink, ".comment");

            let clone = cloneComment(comment);
            markParentLink(parent, clone);
            this.quote_container.appendChild(clone);

            let parentRect = this.comment_list.getBoundingClientRect();
            let style = this.quote_container.style;
            style.top = quoteLink.getBoundingClientRect().bottom + fQuery.scrollTop() + 8 + "px";
            style.left = parentRect.left - 6 + "px";
            style.width = parentRect.width + 12 + "px";

            App.DispatchEvent(this.quote_container, "loadVisibleImages");
        });

        return () => {
            cancel = true;
        };
    },

    endShowQuote() {
        clearTimeout(this.hoverTimeout);
        if (this.quote_container.firstChild !== null) {
            removeElement(this.quote_container.firstChild);
        }
    },

    expandQuote(quoteLink) {
        let parent = fQuery.closestParent(quoteLink, ".comment");

        // Don't expand parent links or links within collapsed comments
        let linkStatus = getQuoteLinkStatus(quoteLink);
        if (linkStatus.parentCollapsed || linkStatus.isParentLink) {
            return;
        }

        this.endShowQuote();

        // This probably causes a bug when two links to the same comment are expanded, and the
        // bottom is collapsed. The top comment will disappear instead of the bottom. I don't think
        // it's important enough to fix though.
        let linkedId = quoteLink.dataset.comment_id;
        let expandedComment = quoteLink.parentNode.querySelector(
            `.comment[data-comment_id='${linkedId}']`
        );
        if (expandedComment === null) {
            this.getComment(linkedId).then(comment => {
                let clone = cloneComment(comment);
                markParentLink(parent, clone);
                clone.classList.add("inline-quote");

                forwardHide(quoteLink, 1);
                quoteLink.classList.add("ct--expanded-link");

                if (!is_mobile && !isCommentDeleted(comment)) {
                    // Add middot after username in .meta to separate it from the index. On mobile,
                    // the username is `display: block;`, so we don't need a separator.
                    fQuery.insertAfter(clone.querySelector(".meta > .name"), createMiddot());
                }

                if (quoteLink.classList.contains("comment_callback")) {
                    // Search backwards through .comment_callbacks for the last quote link, and
                    // place this comment after it. This keeps quote links together at the top and
                    // orders expanded comments from most to least recently expanded.
                    let lastLink = quoteLink.parentElement.lastElementChild;
                    while (lastLink.tagName !== "A") {
                        lastLink = lastLink.previousElementSibling;
                    }
                    fQuery.insertAfter(lastLink, clone);
                } else {
                    fQuery.insertAfter(quoteLink, clone);
                }
            });
        } else {
            // Update forward hiding counts for all expanded links
            for (let expandedLink of expandedComment.querySelectorAll(".ct--expanded-link")) {
                forwardHide(expandedLink, -1);
            }
            removeElement(expandedComment);
            forwardHide(quoteLink, -1);
            quoteLink.classList.remove("ct--expanded-link");
        }
    },

    previous() {
        if (this.current_page > 1) {
            location.hash = `#page/${this.current_page - 1}`;
        }
    },

    next() {
        if (this.current_page < this.num_pages) {
            location.hash = `#page/${this.current_page + 1}`;
        }
    },

    /* Extra methods */

    storeComments() {
        for (let comment of this.comment_list.children) {
            if (isCommentDeleted(comment)) {
                this.commentMetadata[comment.dataset.comment_id] = {
                    author: comment.dataset.author,
                    deleted: true,
                };
            } else {
                let link = comment.querySelector("a[href^='#comment/']");
                this.commentMetadata[comment.dataset.comment_id] = {
                    author: comment.dataset.author,
                    index: Number(link.textContent.slice(1).replace(/,/g, "")),
                };
            }
        }
    },

    rewriteQuoteLinks(elem) {
        for (let quoteLink of elem.querySelectorAll(".comment_quote_link:not(.comment_callback)")) {
            let id = quoteLink.dataset.comment_id;
            let meta = this.commentMetadata[id];
            if (meta !== undefined) {
                if (meta.deleted) {
                    quoteLink.textContent = meta.author;
                    quoteLink.classList.add("ct--deleted-link");
                } else if (this.comment_list.querySelector("#comment_" + id) === null) {
                    // Rewrite cross-page comments
                    quoteLink.textContent = `${meta.author} (${formatCommentIndex(meta.index)})`;
                } else if (is_mobile) {
                    // On mobile, the prototype setupQuotes does nothing. So we have to rewrite all
                    // quote links
                    quoteLink.textContent = meta.author;
                }
            }
        }
    },
};

// Despite the @run-at option, the userscript is sometimes run before the Fimfiction JS, which
// causes errors. So, we wait for the page to be fully loaded.
if (document.readyState === "complete") {
    init();
} else {
    window.addEventListener("load", init);
}

function init() {
    let storyComments = document.getElementById("story_comments");
    if (storyComments === null) {
        return;
    }

    let style = document.createElement("style");
    style.textContent = ctCSS;
    document.head.appendChild(style);

    commentController = App.GetControllerFromElement(storyComments);
    comment_list = commentController.comment_list;
    Object.assign(commentController, commentControllerShell);

    commentController.storeComments();
    if (is_mobile) {
        commentController.rewriteQuoteLinks(comment_list);
    }
    setupCollapseButtons();

    setupEventListeners();

    // quote_container is used by beginShowQuote to store the hovered quote (when there is one). In
    // the original code, it's checked for on each call. Here, we create it at init.
    if (commentController.quote_container === null) {
        let container = document.createElement("div");
        container.className = "quote_container";
        document.body.appendChild(container);
        commentController.quote_container = container;
    }
}

function setupEventListeners() {
    fQuery.addScopedEventListener(comment_list, ".ct--collapse-button", "click", evt =>
        toggleCollapseCommentTree(fQuery.closestParent(evt.target, ".comment"))
    );

    let cancelCallback = null;
    fQuery.addScopedEventListener(comment_list, ".comment_quote_link", "mouseover", evt => {
        evt.stopPropagation();
        // Mouseover events can sometimes be triggered on mobile, but there's no point. They
        // just block the page.
        if (is_mobile) {
            return;
        }
        // Don't show popup quote for expanded links, links within collapsed comments, or links
        // to the parent comment
        let linkStatus = getQuoteLinkStatus(evt.target);
        if (!linkStatus.isExpanded && !linkStatus.parentCollapsed && !linkStatus.isParentLink) {
            commentController.hoverTimeout = setTimeout(() => {
                cancelCallback = commentController.beginShowQuote(evt.target);
            }, QUOTE_LINK_HOVER_DELAY);
        }
    });
    fQuery.addScopedEventListener(comment_list, ".comment_quote_link", "mouseout", () => {
        if (cancelCallback !== null) {
            cancelCallback();
            cancelCallback = null;
        }
    });

    for (let binder of App.globalBinders) {
        // These event listeners are "global binders." That is, they are added to each element that
        // matches the selector. But because this binding is only done at load (and in a few other
        // cases), and cloneNode does not copy event listeners, the listeners will not fire for
        // expanded comments. We add a scoped listener (with ".inline-quote" prepended so that
        // listeners don't fire twice) to fix this.
        if (binder.class === "user_image_link" || binder.class === "youtube_container") {
            fQuery.addScopedEventListener(
                comment_list,
                ".inline-quote " + binder.selector,
                binder.event,
                binder.binder
            );
        } else if (binder.class === "embed-container") {
            // Remove this listener. We replace it with a modified version below that hides
            // .placeholder instead of removing it. This way, we can restore the .placeholder when
            // cloning comments.
            for (let elem of document.querySelectorAll(binder.selector)) {
                elem.removeEventListener(binder.event, binder.binder);
            }
            // Disable the binder so we only have to remove listeners once
            binder.selector = binder.class = "";
        }
    }

    // Embed containers can occur outside of comments (e.g. in blog posts). So, we must scope this
    // listener to the whole page.
    fQuery.addScopedEventListener(document.body, ".embed-container", "click", evt => {
        let elem = fQuery.closestParent(evt.target, ".embed-container");
        if (elem.classList.contains("expanded")) {
            return;
        }

        let cookieConsent = App.GetDependency("cookieConsent");
        cookieConsent.requestConsent(["embed"]).then(
            () => {
                elem.classList.add("expanded");
                elem.querySelector(".video").innerHTML = `<iframe src="${
                    elem.dataset.src
                }" frameborder="0" allowfullscreen></iframe>`;
                elem.querySelector(".placeholder").classList.add("hidden");
            },
            () => {
                ShowErrorWindow("Cannot view embedded content without consenting to embed cookies");
            }
        );
    });
}

function isCommentDeleted(comment) {
    return (
        comment.firstElementChild.classList.contains("message") &&
        comment.lastElementChild.classList.contains("hidden")
    );
}

function forwardHide(quoteLink, change) {
    // Callbacks expand newer comments into older ones. So, in ASC order (oldest to newest), we
    // forward hide when expanding callbacks. Non-callbacks expand older comments. So, in DESC order
    // (newest to oldest), we forward hide when expanding non-callbacks.
    let isCallback = quoteLink.classList.contains("comment_callback");
    let isASC = commentController.order === "ASC";
    if (isCallback !== isASC) {
        return;
    }

    let comment = comment_list.querySelector("#comment_" + quoteLink.dataset.comment_id);
    // Don't hide foreign comments
    if (comment === null) {
        return;
    }

    let newCount = Number(comment.dataset.expandCount || 0) + change;
    if (newCount < 0) {
        throw new Error("Expand count cannot be less than 0");
    } else if (newCount === 0) {
        comment.classList.remove("ct--forward-hidden");
    } else if (newCount === 1) {
        comment.classList.add("ct--forward-hidden");
    }
    comment.dataset.expandCount = newCount;
}

function setupCollapseButtons() {
    for (let metaName of comment_list.querySelectorAll(".meta > .name")) {
        fQuery.insertAfter(metaName, createMiddot());

        let collapseButton = document.createElement("a");
        collapseButton.classList.add("ct--collapse-button");
        let minus = document.createElement("i");
        minus.classList.add("fa", "fa-minus-square-o");
        collapseButton.appendChild(minus);
        fQuery.insertAfter(metaName, collapseButton);
    }
}

function toggleCollapseCommentTree(comment) {
    collapseCommentTree(comment, !comment.classList.contains("ct--collapsed-comment"));
}
function collapseCommentTree(comment, collapse) {
    comment.classList.toggle("ct--collapsed-comment", collapse);

    let collapseIcon = comment.querySelector(".ct--collapse-button > i");
    collapseIcon.classList.toggle("fa-plus-square-o", collapse);
    collapseIcon.classList.toggle("fa-minus-square-o", !collapse);

    // We always collapse comments which appear later in the comment list. Exactly which quote links
    // we search through depends on the sorting order.
    let comment_id = comment.dataset.comment_id;
    if (commentController.order === "ASC") {
        // We are careful to not select any quote links in expanded comments
        let quoteLinks = comment.querySelectorAll(`#comment_callbacks_${comment_id} > a`);
        for (let quoteLink of quoteLinks) {
            let nextComment = comment_list.querySelector(
                "#comment_" + quoteLink.dataset.comment_id
            );
            collapseCommentTree(nextComment, collapse);
        }
    } else {
        // There's no easy way to select the quote links in the .data of this comment and ignore
        // links in expanded comments. It would require a :not(descendant of inline quote) selector,
        // which isn't possible. So, we select callbacks which point to the current comment, and
        // then get the comments which have those callbacks. This seems inefficient, but it only
        // uses DOM lookups, and doesn't require extracting and storing data from the DOM, which I
        // think would increase complexity.
        let quoteLinks = comment_list.querySelectorAll(
            `span[id^='comment_callbacks_'] > a[data-comment_id='${comment_id}']`
        );
        for (let quoteLink of quoteLinks) {
            collapseCommentTree(fQuery.closestParent(quoteLink, ".comment"), collapse);
        }
    }
}

// Clone a comment and reset it
function cloneComment(comment) {
    let clone = comment.cloneNode(true);

    clone.removeAttribute("id");
    // Needed for comment collapsing
    let callbacks = clone.querySelector(".comment_callbacks");
    if (callbacks !== null) {
        callbacks.removeAttribute("id");
    }
    // Get rid of the blue highlight caused by clicking on the comment's index or posting date
    clone.classList.remove("comment_selected");

    // Remove quotes
    for (let inlineQuote of clone.querySelectorAll(".inline-quote")) {
        removeElement(inlineQuote);
    }

    // Remove ct classes. We don't remove parent-link-highlight because it's only applied to links
    // in expanded comments. It should be fine to leave deleted-link.
    clone.classList.remove("ct--forward-hidden");
    clone.classList.remove("ct--collapsed-comment");
    for (let expandedLink of clone.querySelectorAll(".ct--expanded-link")) {
        expandedLink.classList.remove("ct--expanded-link");
    }

    // Remove middot and collapse button
    let collapseButton = clone.querySelector(".ct--collapse-button");
    if (collapseButton !== null) {
        removeElement(collapseButton.nextElementSibling);
        removeElement(collapseButton);
    }

    // Reset .embed-container
    for (let embedContainer of clone.querySelectorAll(".embed-container")) {
        embedContainer.classList.remove("expanded");
        let frame = embedContainer.querySelector(".video").firstChild;
        if (frame !== null) {
            removeElement(frame);
        }
        embedContainer.querySelector(".placeholder").classList.remove("hidden");
    }

    return clone;
}

// Disable links to the parent comment to help prevent infinite nesting. Also highlight the link if
// there are other links in its section.
function markParentLink(parentComment, childComment) {
    let parentId = parentComment.dataset.comment_id;
    let linkToParent = childComment.querySelector(
        `.comment_quote_link[data-comment_id='${parentId}']`
    );
    if (linkToParent !== null) {
        // If there are other links in this quote link's section (comment data or callbacks), mark
        // this link for visibility
        let otherLink = fQuery
            .closestParent(linkToParent, ".comment_data, .comment_callbacks")
            .querySelector(`.comment_quote_link:not([data-comment_id='${parentId}'])`);
        if (otherLink !== null) {
            linkToParent.classList.add("ct--parent-link-highlight");
        }
        // This prevents the link from being expanded
        linkToParent.dataset.parentLink = true;
    }
}

function getQuoteLinkStatus(quoteLink) {
    return {
        isExpanded: quoteLink.classList.contains("ct--expanded-link"),
        isParentLink: quoteLink.dataset.parentLink,
        parentCollapsed: fQuery
            .closestParent(quoteLink, ".comment")
            .classList.contains("ct--collapsed-comment"),
    };
}

// https://stackoverflow.com/a/2901298
function formatCommentIndex(index) {
    return ("#" + index).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

function createMiddot() {
    let middot = document.createElement("b");
    middot.textContent = "\u00b7";
    return middot;
}

function removeElement(elem) {
    elem.parentNode.removeChild(elem);
}