StackOverflow extended

Copy code to clipboard; hiding and saving the state of the "Blog", "Meta" blocks by clicking; adding links to all questions of the author and all questions only with tags of the current question to the user's card; stretching and restoring page content for better reading of code listings; redirecting from localized versions of the site to an English-language domain with a search for the current question.

当前为 2022-09-13 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        StackOverflow extended
// @namespace   https://github.com/XelaNimed
// @version     0.10.0
// @description Copy code to clipboard; hiding and saving the state of the "Blog", "Meta" blocks by clicking; adding links to all questions of the author and all questions only with tags of the current question to the user's card; stretching and restoring page content for better reading of code listings; redirecting from localized versions of the site to an English-language domain with a search for the current question.
// @author      XelaNimed
// @copyright   2021, XelaNimed (https://github.com/XelaNimed)
// @match       https://*.stackoverflow.com/*
// @match       https://*.meta.stackoverflow.com/*
// @grant       GM_getResourceText
// @grant       GM_addStyle
// @homepageURL https://github.com/XelaNimed/ruSO
// @supportURL  https://github.com/XelaNimed/ruSO/issues
// @iconURL     https://www.google.com/s2/favicons?domain=stackoverflow.com&sz=32
// @require     https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.1/jquery.min.js#sha512-aVKKRRi/Q/YV+4mjoKBsE4x3H+BkegoM/em46NNlCqNTmUYADjBbeNefNxYV7giUp0VxICtqdrbqU7iVaeZNXA==
// @require     https://cdnjs.cloudflare.com/ajax/libs/izimodal/1.6.1/js/iziModal.min.js#sha512-lR/2z/m/AunQdfBTSR8gp9bwkrjwMq1cP0BYRIZu8zd4ycLcpRYJopB+WsBGPDjlkJUwC6VHCmuAXwwPHlacww==
// @resource    IZI_MODAL https://cdnjs.cloudflare.com/ajax/libs/izimodal/1.6.1/css/iziModal.min.css
// @license     MIT
// ==/UserScript==


var $ = window.jQuery;

var ruSO = {
    $sidebar: $('#sidebar'),
    $content: $('#content'),
    $container: $('body>.container'),
    $fullWidthBtn: null,
    params: {
        animationSpeed: 250
    },
    keys: {
        showMetasKey: 'showMetaPosts',
        contentMaxWidth: 'contentMaxWidth',
        containerMaxWidth: 'containerMaxWidth',
        fooFullWidth: 'fooFullWidth',
        nativeLang: 'nativeLanguage',
        useSearchRedirectBtn: 'useSearchRedirectBtn',
        addLinkToMeta: 'addLinkToMeta',
        toggleMetaBlock: 'toggleMetaBlock'
    },
    strings: {
        clickToToggle: 'Скрыть/показать',
        setFullWidth: 'Растянуть',
        resetFullWidth: 'Восстановить',
        copy: 'Копировать',
        copied: 'Скопировано',
        canNotCopy: 'Упс, ошибка',
        intoClipboard: 'В буфер'
    },

    // local staorage access
    isUseSearchRedirectBtn: function() {
        return localStorage[this.keys.useSearchRedirectBtn] == 'true';
    },
    getNativeLang: function() {
        return localStorage[this.keys.nativeLang];
    },
    isNativeLang: function(lang) {
        return localStorage[this.keys.nativeLang] === lang;
    },
    addLinkToMeta: function() {
        return localStorage[this.keys.addLinkToMeta] == 'true';
    },
    toggleMetaBlock: function() {
        return localStorage[this.keys.toggleMetaBlock] == 'true';
    },

    init: function() {

        'use strict';

        let self = this;

        const langs = ['ru', 'es', 'pt', 'ja'];

        if(localStorage[self.keys.nativeLang] === undefined ||
           localStorage[self.keys.nativeLang] == null ||
           localStorage[self.keys.nativeLang] === '') {
            const lang = navigator.language || navigator.userLanguage;
            if(langs.includes(lang)) {
                localStorage[self.keys.nativeLang] = lang;
            } else {
                localStorage[self.keys.useSearchRedirectBtn] = false;
            }
        }
        if(localStorage[self.keys.useSearchRedirectBtn] === undefined ||
           localStorage[self.keys.useSearchRedirectBtn] == null ||
           localStorage[self.keys.useSearchRedirectBtn] === '') {
           localStorage[self.keys.useSearchRedirectBtn] = true;
        }
        if(localStorage[self.keys.toggleMetaBlock] === undefined ||
           localStorage[self.keys.toggleMetaBlock] == null ||
           localStorage[self.keys.toggleMetaBlock] === '') {
           localStorage[self.keys.toggleMetaBlock] = true;
        }

        $(document.body).append('<div id="iziModal" style="display: none;">' +

                                    '<div class="izi-content ba">' +

                                        '<div class="d-flex ai-center jc-space-between p16">' +
                                            '<label class="flex--item s-label p0" for="so-ex-search-btn-toggle">' +
                                                '<div class="d-flex ai-center">Use redirect to enSO</div>' +
                                                '<p class="s-description">When this option is enabled, a button redirecting the current search to the English-language StackOverflow site will be added at the end of the search field.</p>' +
                                            '</label>' +
                                            '<div class="flex--item s-toggle-switch">' +
                                                '<input id="so-ex-search-btn-toggle" type="checkbox" ' + (self.isUseSearchRedirectBtn() ? 'checked="checked"' : '') + '>' +
                                                '<div class="s-toggle-switch--indicator"></div>' +
                                            '</div>' +
                                        '</div>' +

                                        '<div id="so-ext-native-language-block" class="d-flex ai-center jc-space-between p16' + (self.isUseSearchRedirectBtn() ? '' : ' o50 pe-none') + '">' +
                                            '<label class="s-label flex--item" for="so-ext-native-language">Native language' +
                                                '<p class="s-description">The two-letter code of your language, if it is different from English. Used when redirecting search queries from the localized site to the English version and back.</p>' +
                                            '</label>' +
                                            '<div class="d-flex">' +
                                                '<select id="so-ext-native-language" class="flex--item s-input" style="width: 75px;" autofocus="true">' +
                                                    (langs.flatMap(function(l) { return '<option value="' + l + '"' + (self.isNativeLang(l) ? ' selected="selected"' : '') + '>' + l + '</option>'; }).join('')) +
                                                '</select>' +
                                            '</div>' +
                                        '</div>' +

                                        '<div class="d-flex ai-center jc-space-between p16">' +
                                            '<label class="flex--item s-label p0" for="so-ex-add-meta-link">' +
                                                '<div class="d-flex ai-center">Add link to Meta</div>' +
                                                '<p class="s-description">If this option is enabled, a link to Meta will be added to the side menu.</p>' +
                                            '</label>' +
                                            '<div class="flex--item s-toggle-switch">' +
                                                '<input id="so-ex-add-meta-link" type="checkbox" ' + (self.addLinkToMeta() ? 'checked="checked"' : '') + '>' +
                                                '<div class="s-toggle-switch--indicator"></div>' +
                                            '</div>' +
                                        '</div>' +

                                        '<div class="d-flex ai-center jc-space-between p16">' +
                                            '<label class="flex--item s-label p0" for="so-ex-toggle-meta-block">' +
                                                '<div class="d-flex ai-center">Minimize the Meta block</div>' +
                                                '<p class="s-description">If this option is enabled, the Meta block with popular questions can be minimized and maximized with the state saved in the local storage.</p>' +
                                            '</label>' +
                                            '<div class="flex--item s-toggle-switch">' +
                                                '<input id="so-ex-toggle-meta-block" type="checkbox" ' + (self.toggleMetaBlock() ? 'checked="checked"' : '') + '>' +
                                                '<div class="s-toggle-switch--indicator"></div>' +
                                            '</div>' +
                                        '</div>' +

                                        '<div class="d-flex ai-center p16 button-panel">' +
                                            '<button class="flex--item s-btn s-btn__filled" role="button" value="cancel">Cancel</button>' +
                                            '<button class="flex--item s-btn s-btn__filled" role="button" value="save">Save</button>' +
                                            '<button class="flex--item s-btn s-btn__primary" role="button" value="save-reload">Save and reload</button>' +
                                        '</div>' +

                                    '</div>' +

                                '</div>');

        GM_addStyle(GM_getResourceText("IZI_MODAL"));
        GM_addStyle('#iziModal {  }' +
                    '#iziModal .izi-content { background-color: rgb(57, 57, 57); border-color: rgb(64, 66, 69); color: rgb(207, 210, 214); }' +
                    '#iziModal .izi-content > div { border-bottom: 1px solid rgba(245, 245, 245, 0.2); }' +
                    '#iziModal label { color: rgb(242, 242, 243); }' +
                    '#iziModal .s-description { color: rgb(207, 210, 214); }' +
                    '#iziModal .button-panel { justify-content: flex-end; background-color: rgb(45, 45, 45); }' +
                    '#iziModal .button-panel button { margin-left: 10px; }' +
                    'body > div.iziModal-overlay { backdrop-filter: blur(2px); }');

        return this;
    },
    initLocalStorage: function initLocalStorage() {

        if(localStorage.getItem(this.keys.showMetasKey) == null)
        {
            localStorage.setItem(this.keys.showMetasKey, true);
        }

        localStorage[this.keys.containerMaxWidth] = this.$container.css('max-width');
        localStorage[this.keys.contentMaxWidth] = this.$content.css('max-width');
        localStorage[this.keys.fooFullWidth] = 'setFullWidth';
        return this;
    },
    addButtons: function () {
        var self = this,
        addScriptSettings = function() {
            $('<li><ol class="nav-links"><a href="#" class="nav-links--link">UserScript settings</a></li></ol></li>')
                .on('click', 'a', function(e) {
                  $("#iziModal").iziModal({
                      title: '<span style="color: rgb(207, 210, 214);">Extended StackOverflow Settings</span>',
                      subtitle: 'All settings are saved in the local storage and will take effect when the page reloads',
                      headerColor: 'rgba(45, 45, 45, 1)',
                      background: 'rgba(78, 78, 71, 1)',
                      radius: 3,
                      icon: null,
                      iconText: null,
                      iconColor: '',
                      width: 600,
                      borderBottom: false,
                      zindex: 9999,
                      focusInput: true,
                      bodyOverflow: false,
                      fullscreen: true,
                      openFullscreen: false,
                      appendToOverlay: 'body', // or false
                      overlay: true,
                      overlayClose: true,
                      overlayColor: 'rgba(0, 0, 0, 0.3)'
                  }).iziModal('open');
                })
                .insertAfter($('#left-sidebar nav > ol > li').last());

         $('#iziModal')
            .on('click', 'button', function(e) {
                 if(e.target.value.startsWith('save')) {
                     localStorage[self.keys.nativeLang] = $('#so-ext-native-language option:selected').val();
                     localStorage[self.keys.useSearchRedirectBtn] = $('#so-ex-search-btn-toggle').is(':checked');
                     localStorage[self.keys.addLinkToMeta] = $('#so-ex-add-meta-link').is(':checked');
                     localStorage[self.keys.toggleMetaBlock] = $('#so-ex-toggle-meta-block').is(':checked');
                 }
                 if(e.target.value.endsWith('reload')) {
                     document.location.reload();
                 }
                 $('#iziModal').iziModal('close');
            })
            .on('change', 'input', function(e) {
               if(e.target.id == 'so-ex-search-btn-toggle') {
                   $('#so-ext-native-language-block')[e.target.checked ? 'removeClass' : 'addClass']('o50 pe-none');
               }
            });
        },
        addWatchedTags = function () {
            let tags = [],
            urlPrefix = window.location.origin + '/questions/tagged/';
            $('.js-watched-tag-list a.user-tag').each(function (idx, itm) {
                let url = itm.href;
                tags.push(url.substring(url.lastIndexOf('/') + 1));
            });
            if (tags.length) {
                let url = urlPrefix + tags.join('+or+');
                let $header = self.$sidebar.find(".js-tag-preferences-container > div").first().find("h2");
                if ($header.length > 0) {

                    $header[0].innerHTML = '<a class="post-tag user-tag" href="' + url + '">' + $header.text() + '</a>';
                }
            }
        },
        addMetaToggles = function () {
            if(!self.toggleMetaBlock()) {
                return;
            }
            let showHideMetas = function ($elem) {
                let isVisible = localStorage[self.keys.showMetasKey] === 'true';
                let $elems = $elem.parent().children('li.s-sidebarwidget--item');
                $elems.each(function(idx, itm){
                    let $itm = $(itm);
                    if(isVisible)
                    {
                        $itm.removeAttr('style');
                    } else {
                         $itm.attr('style', 'display: none !important');
                    }
                });
            };
            self.$sidebar
            .find('div.s-sidebarwidget li.s-sidebarwidget--header')
            .each(function (idx, itm) {
                let $itm = $(itm);
                $itm
                .attr('title', ruSO.strings.clickToToggle)
                .css('cursor', 'pointer')
                .on('click', function (e) {
                    let isVisible = localStorage.getItem(self.keys.showMetasKey) === 'true';
                    localStorage.setItem(self.keys.showMetasKey, !isVisible);
                    showHideMetas($(e.target));
                });
                showHideMetas($itm);
            });
        },
        addLinkToMeta = function () {
            if(!self.addLinkToMeta()) {
                return;
            }
            const isMeta = window.location.host.includes('meta.');
            const link = isMeta
                  ? window.location.host.split('.').filter(part => part !== 'meta').join('.')
                  : 'meta.' + window.location.host;
            const linkText = isMeta ? 'StackOverflow' : 'Meta'
            $('<li><ol class="nav-links"><a href="https://' + link + '" class="nav-links--link">' + linkText + '</a></ol></li>').insertAfter($('#left-sidebar nav > ol > li').last());
        },
        addFullWidth = function () {
            let $header = $('#question-header');
            self.$fullWidthBtn = $header.find('div').clone();
            self.$fullWidthBtn.attr('id', 'set-full-width-btn').find('a')
            .removeClass('s-btn__primary')
            .addClass('s-btn__filled')
            .attr('href', '#')
            .text(self.strings.setFullWidth)
            .on('click', function () {
                self[localStorage[self.keys.fooFullWidth]]();
            });
            $header.append(self.$fullWidthBtn);
        },
        addRedirectToSO = function () {
            if(!self.isUseSearchRedirectBtn()) {
                return;
            }
            let localPrefix = self.getNativeLang() + '.';
            let isLocalSO = location.host.substr(0, 3) === localPrefix;
            let btnText = isLocalSO ? 'en' : self.getNativeLang();
            let $btn = $('<div class="print:d-none"><a href="#" class="s-btn s-btn__filled s-btn__xs s-btn__icon ws-nowrap">' + btnText + '</a></div>');
            $btn.insertAfter($('#search'));
            $btn.on('click', function () {
                location.host = isLocalSO
                     ? location.host.substr(localPrefix.length)
                     : localPrefix + location.host;
            });
        };
        addWatchedTags();
        addMetaToggles();
        addLinkToMeta();
        addFullWidth();
        addRedirectToSO();
        addScriptSettings();
        return this;
    },
    addAuthorQuestionsLinks: function () {
        let $userDetails = $('div.owner > div.user-info > div.user-details');
        if ($userDetails.length > 0) {
            let $postTags = $('div.post-taglist').find('a.post-tag');
            let tags = [];
            for (let i = 0; i < $postTags.length; i++) {
                tags.push('[' + $postTags[i].href.split('/').slice(-1).pop() + ']');
            }
            let tagsUrl = tags.join('+or+');
            for (let i = 0; i < $userDetails.length; i++) {
                let $userDetail = $($userDetails[i]);
                let $userUrl = $userDetail.find('a');
                let userName = $userUrl.text();
                let userId = $userUrl[0].href.split('/')[4];
                let baseSearhcUrl = 'https://ru.stackoverflow.com/search?tab=newest&q=user%3A' + userId + '+is%3Aq';
                let elem = '<span>? <a href="' + baseSearhcUrl + '" title="Все вопросы ' + userName + '">все</a>';
                if (tags.length > 0) {
                    elem += ', <a href="' + baseSearhcUrl + '+' + tagsUrl + '" title="Вопросы ' + userName + ' с метками текущего вопроса">с такими-же метками</a>';
                }
                elem += '</span>';
                $(elem).insertAfter($userDetail);
            }
        }
        return this;
    },
    setFullWidth: function () {
        this.$container.add(this.$content).css({
            'max-width': 'none'
        });
        this.$fullWidthBtn.find('a').text(this.strings.resetFullWidth);
        localStorage[this.keys.fooFullWidth] = 'resetFullWidth';
        return this;
    },
    resetFullWidth: function () {
        this.$container.css({
            'max-width': localStorage[this.keys.containerMaxWidth]
        });
        this.$content.css({
            'max-width': localStorage[this.keys.contentMaxWidth]
        });
        this.$fullWidthBtn.find('a').text(this.strings.setFullWidth);
        localStorage[this.keys.fooFullWidth] = 'setFullWidth';
        return this;
    },
    selectElemText: function(elem) {
		let range = document.createRange();
        range.selectNodeContents(elem);
        let sel = window.getSelection();
        sel.removeAllRanges();
        sel.addRange(range);
    },
    getSelectedText: function() {
        let text = '';
        if (window.getSelection) {
            text = window.getSelection();
        } else if (document.getSelection) {
            text = document.getSelection();
        } else if (document.selection) {
            text = document.selection.createRange().text;
        }
        return text;
    },
    copyToClipboard: function(text) {
        if (window.clipboardData && window.clipboardData.setData) {
            return window.clipboardData.setData("Text", text);
        } else if (document.queryCommandSupported && document.queryCommandSupported("copy")) {
            let textarea = document.createElement("textarea");
            textarea.textContent = text;
            textarea.style.position = "fixed";
            document.body.appendChild(textarea);
            textarea.select();
            try {
                return document.execCommand("copy");
            } catch (ex) {
                console.warn("Copy to clipboard failed", ex);
                return false;
            } finally {
                document.body.removeChild(textarea);
            }
        }
    },
	addCopyToClipboard: function() {

		let self = this;

        let toClipboard = function($elems) {

        };

        $('.snippet-ctas').each(function() {
            let $el = $(this);
            let $availableBtn = $el.find('.copySnippet');
            let $snipBtn = $availableBtn.clone();
            $snipBtn.val(self.strings.intoClipboard);
            $snipBtn.click(function() {

                let code = "";

                $snipBtn.closest('.snippet-code').find('pre > code').each(function() {
                    let $this = $(this);
                    self.selectElemText(this);
                    let selectedText = self.getSelectedText();
                    code += selectedText + '\n';
                    window.getSelection().removeAllRanges();
                });

                if(self.copyToClipboard(code)) {
                    $snipBtn.val(self.strings.copied);
                } else {
                    $snipBtn.val(self.strings.canNotCopy);
                }

                setTimeout(function () {
                    $snipBtn.val(self.strings.intoClipboard);
                }, 2000);
            });
            $availableBtn.after($snipBtn);
        });

        $("pre").each(function () {

            let $pre = $(this);
            let $parent = $pre.parent();

            if($parent.hasClass('snippet-code')) {
                let padding = ($parent.innerWidth() - $parent.width()) / 2;
                $pre.wrapAll('<div style="position: relative; padding-bottom: ' + padding + 'px;"></div>');
            } else {
                $pre.wrapAll('<div style="position: relative;"></div>');
            }

            let $btn = $("<button class='copy-code-button s-btn s-btn__filled s-btn__xs'>" + self.strings.copy + "</button>");
            $btn.css({
                "position": "absolute",
                "top": "6px",
                "right": "12px",
                "display": "none"
            });
            $pre.append($btn);

            let $container = $btn.siblings("code");
            $pre.hover(function () {
                $btn.css("display", "block");
            }, function () {
                $btn.css("display", "none");
            });

            setTimeout(function () {
                if ($container.length == 0) {
                    $pre.contents().filter(function () {
                        return this.className !== "copy-code-button";
                    }).wrapAll('<code style= "overflow-x: auto; padding: 0px;"></code>');
                    $container = $btn.siblings("code").get(0);
                } else {
                    $container = $container.get(0);
                }
            }, 0);

            $btn.click(function () {
                self.selectElemText($container);
                let selectedText = self.getSelectedText();
                let buttonNewText = "";
                if (self.copyToClipboard(selectedText) == true) {
                    buttonNewText = self.strings.copied;
                } else {
                    buttonNewText = self.strings.canNotCopy;
                }
                window.getSelection().removeAllRanges();
                $(this).text(buttonNewText);
                let that = this;
                setTimeout(function () {
                    $(that).text(self.strings.copy);
                }, 400);
            });
        });
        return this;
    }
};

ruSO.init();

window.addEventListener('load', function () {
    ruSO
    .initLocalStorage()
    .addButtons()
    .addAuthorQuestionsLinks()
    .addCopyToClipboard();
}, false);