您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds a context menu (right click, long press, command click, etc) to comment boxes on Stack Exchange with customizable pre-written responses.
当前为
// ==UserScript== // @name Stack Exchange comment template context menu // @namespace http://ostermiller.org/ // @version 1.00 // @description Adds a context menu (right click, long press, command click, etc) to comment boxes on Stack Exchange with customizable pre-written responses. // @include /https?\:\/\/([a-z\.]*\.)?(stackexchange|askubuntu|superuser|serverfault|stackoverflow|answers\.onstartups)\.com\/.*/ // @exclude *://chat.stackoverflow.com/* // @exclude *://chat.stackexchange.com/* // @exclude *://chat.*.stackexchange.com/* // @exclude *://api.*.stackexchange.com/* // @exclude *://data.stackexchange.com/* // @connect ostermiller.org // @connect raw.githubusercontent.com // @connect * // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_xmlhttpRequest // ==/UserScript== (function() { 'use strict' // Access to JavaScript variables from the Stack Exchange site var $ = unsafeWindow.jQuery var seOpts = unsafeWindow.StackExchange.options || {site:"",user:{}} // eg. physics.stackexchange.com -> physics function validateSite(s){ var m = /^((?:meta\.)?[a-z0-9]+(?:\.meta)?)\.?[a-z0-9\.]*$/.exec(s.toLowerCase().trim().replace(/^(https?:\/\/)?(www\.)?/,"")) if (!m) return null return m[1] } function validateTag(s){ return s.toLowerCase().trim().replace(/ +/,"-") } // eg hello-world, hello-worlds, hello world, hello worlds, and hw all map to hello-world function makeFilterMap(s){ var m = {} s=s.split(/,/) for (var i=0; i<s.length; i++){ // original m[s[i]] = s[i] // plural m[s[i]+"s"] = s[i] // with spaces m[s[i].replace(/-/g," ")] = s[i] // plural with spaces m[s[i].replace(/-/g," ")+"s"] = s[i] // abbreviation m[s[i].replace(/([a-z])[a-z]+(-|$)/g,"$1")] = s[i] } return m } var userMapInput = "moderator,user" var userMap = makeFilterMap(userMapInput) function validateUser(s){ return userMap[s.toLowerCase().trim()] } var typeMapInput = "question,answer,edit-question,edit-answer,close-question,flag-comment,flag-question,flag-answer,decline-flag,helpful-flag" var typeMap = makeFilterMap(typeMapInput) typeMap.c = 'close-question' typeMap.close = 'close-question' function loadComments(url){ console.log("Loading comments from " + url) GM_xmlhttpRequest({ method: "GET", url: url, onload: function(r){ comments = parseComments(r.responseText) if (!comments || !comments.length){ alert("No comment templates loaded from " + url) return } storeComments() if(GM_getValue(storageKeys.url)){ GM_setValue(storageKeys.lastUpdate, Date.now()) } }, onerror: function(){ alert("Could not load comment templates from " + url) } }) } function validateType(s){ return typeMap[s.toLowerCase().trim()] } // Map of functions that clean up the filter-tags on comment templates var tagValidators = { tags: validateTag, sites: validateSite, users: validateUser, types: validateType } // Given a filter tag name and an array of filter tag values, // clean up and canonicalize each of them // Put them into a hash set (map each to true) for performant lookups function validateAllTagValues(tag, arr){ var ret = {} for (var i=0; i<arr.length; i++){ // look up the validation function for the filter tag type and call it var v = tagValidators[tag](arr[i]) // Put it in the hash set if (v) ret[v]=1 } if (Object.keys(ret).length) return ret return null } // List of keys used for storage, centralized for multiple usages var storageKeys = { comments: "ctcm-comments", url: "ctcm-url", lastUpdate: "ctcm-last-update" } // On-load, parse comment templates from local storage var comments = parseComments(GM_getValue(storageKeys.comments)) // The download comment templates from URL if configured if(GM_getValue(storageKeys.url)){ loadStorageUrlComments() } else if (!comments || !comments.length){ // If there are NO comments, fetch the defaults loadComments("https://ostermiller.deadsea.ostermiller.org/stack-exchange-comments.txt") } checkCommentLengths() function checkCommentLengths(){ for (var i=0; i<comments.length; i++){ var c = comments[i] var length = c.comment.length; if (length > 600){ console.log("Comment template is too long (" + length + "/600): " + c.title) } else if (length > 500 && (c.types['flag-question'] || c.types['flag-answer'])){ console.log("Comment template is too long for flagging posts (" + length + "/500): " + c.title) } else if (length > 300 && (c.types['edit-question'] || c.types['edit-answer'])){ console.log("Comment template is too long for an edit (" + length + "/300): " + c.title) } else if (length > 200 && (c.types['decline-flag'] || c.types['helpful-flag'])){ console.log("Comment template is too long for flag handling (" + length + "/200): " + c.title) } else if (length > 200 && c.types['flag-comment']){ console.log("Comment template is too long for flagging comments (" + length + "/200): " + c.title) } } } // Serialize the comment templates into local storage function storeComments(){ if (!comments || !comments.length) GM_deleteValue(storageKeys.comments) else GM_setValue(storageKeys.comments, exportComments()) } function parseComments(s){ if (!s) return [] var lines = s.split(/\n|\r|\r\n/) var c, m, cs = [] for (var i=0; i<lines.length; i++){ var line = lines[i].trim() if (!line){ // Blank line indicates end of comment if (c && c.title && c.comment) cs.push(c) c=null } else { // Comment template title // Starts with # // May contain type filter tag abbreviations (for compat with SE-AutoReviewComments) // eg # Comment title // eg ### [Q,A] Commment title m = /^#+\s*(?:\[([A-Z,]+)\])?\s*(.*)$/.exec(line); if (m){ // Stash previous comment if it wasn't already ended by a new line if (c && c.title && c.comment) cs.push(c) // Start a new comment with title c={title:m[2]} // Handle type filter tags if they exist if (m[1]) c.types=validateAllTagValues("types",m[1].split(/,/)) } else if (c) { // Already started parsing a comment, look for filter tags and comment body m = /^(sites|types|users|tags)\:\s*(.*)$/.exec(line); if (m){ // Add filter tags c[m[1]]=validateAllTagValues(m[1], m[2].split(/,/)) } else { // Comment body (join multiple lines with spaces) if (c.comment) c.comment=c.comment+" "+line else c.comment=line } } else { // No comment started, didn't find a comment title console.log("Could not parse line from comment templates: " + line) } } } // Stash the last comment if it isn't followed by a new line if (c && c.title && c.comment) cs.push(c) return cs } function sort(arr){ if (!(arr instanceof Array)) arr = Object.keys(arr) arr.sort() return arr } function exportComments(){ var s =""; for (var i=0; i<comments.length; i++){ var c = comments[i] s += "# " + c.title + "\n" s += c.comment + "\n" if (c.types) s += "types: " + sort(c.types).join(", ") + "\n" if (c.sites) s += "sites: " + sort(c.sites).join(", ") + "\n" if (c.users) s += "users: " + sort(c.users).join(", ") + "\n" if (c.tags) s += "tags: " + sort(c.tags).join(", ") + "\n" s += "\n" } return s; } // inner lightbox content area var ctcmi = $('<div id=ctcm-menu>') // outer translucent lightbox background that covers the whole page var ctcmo = $('<div id=ctcm-back>').append(ctcmi) GM_addStyle("#ctcm-back{z-index:999998;display:none;position:fixed;left:0;top:0;width:100vw;height:100vh;background:rgba(0,0,0,.5)}") GM_addStyle("#ctcm-menu{z-index:999999;min-width:320px;position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background:white;border:5px solid var(--theme-header-foreground-color);padding:1em;max-width:100vw;max-height:100vh;overflow:auto}") GM_addStyle(".ctcm-body{display:none;background:#EEE;padding:.3em;cursor: pointer;") GM_addStyle(".ctcm-expand{float:right;cursor: pointer;}") GM_addStyle(".ctcm-title{margin-top:.3em;cursor: pointer;}") GM_addStyle("#ctcm-menu textarea{width:90vw;min-width:300px;max-width:1000px;height:60vh;resize:both;display:block}") GM_addStyle("#ctcm-menu input[type='text']{width:90vw;min-width:300px;max-width:1000px;display:block}") GM_addStyle("#ctcm-menu button{margin-top:1em;margin-right:.5em}") // Node input: text field where content can be written. // Used for filter tags to know which comment templates to show in which contexts. // Also used for knowing which clicks should show the context menu, // if a type isn't returned by this method, no menu will show up function getType(node){ var prefix = ""; // Most of these rules use properties of the node or the node's parents // to deduce their context if (node.parents('.js-comment-flag-option').length) return "flag-comment" if (node.parents('.js-flagged-post').length){ if (/decline/.exec(node.attr('placeholder'))) return "decline-flag" else return "helpful-flag" } if (node.parents('.site-specific-pane').length) prefix = "close-" else if (node.parents('.mod-attention-subform').length) prefix = "flag-" else if (node.is('.edit-comment,#edit-comment')) prefix = "edit-" else if(node.is('.js-comment-text-input')) prefix = "" else return null if (node.parents('#question,.question').length) return prefix + "question" if (node.parents('#answers,.answer').length) return prefix + "answer" // Fallback for single post edit page if (node.parents('.post-form').find('h2:last').text()=='Question') return prefix + "question" if (node.parents('.post-form').find('h2:last').text()=='Answer') return prefix + "answer" return null } // Mostly moderator or non-moderator (user.) // Not-logged in and low rep users are not able to comment much // and are unlikely to use this tool, no need to identify them // and give them special behavior. // Maybe add a class for staff in the future? var userclass function getUserClass(){ if (!userclass){ if ($('.js-mod-inbox-button').length) userclass="moderator" else if ($('.my-profile').length) userclass="user" else userclass="anonymous" } return userclass } // The Stack Exchange site this is run on (just the subdoman, eg "stackoverflow") var site function getSite(){ if(!site) site=validateSite(location.hostname) return site } // Which tags are on the question currently being viewed var tags function getTags(){ if(!tags) tags=$.map($('.post-taglist .post-tag'),function(tag){return $(tag).text()}) return tags } // The id of the question currently being viewed var questionid function getQuestionId(){ if (!questionid) questionid=$('.question').attr('data-questionid') if (!questionid) questionid="-" return questionid } // The human readable name of the current Stack Exchange site var sitename function getSiteName(){ if (!sitename){ sitename = seOpts.site.name || "" sitename = sitename.replace(/ ?Stack Exchange/, "") } return sitename } // The Stack Exchange user id for the person using this tool var myUserId function getMyUserId() { if (!myUserId) myUserId = seOpts.user.userId || "" return myUserId } // The full host name of the Stack Exchange site var siteurl function getSiteUrl(){ if (!siteurl) siteurl = location.hostname return siteurl } // Store the comment text field that was clicked on // so that it can be filled with the comment template var commentTextField // Insert the comment template into the text field // called when a template is clicked in the dialog box // so "this" refers to the clicked item function insertComment(){ // The comment to insert is stored in a div // near the item that was clicked var cmt = $(this).parent().children('.ctcm-body').text() // Put in the comment commentTextField.val(cmt).focus() // highlight place for additional input, // if specified in the template var typeHere="[type here]" var typeHereInd = cmt.indexOf(typeHere) if (typeHereInd >= 0) commentTextField[0].setSelectionRange(typeHereInd, typeHereInd + typeHere.length) closeMenu() } // User clicked on the expand icon in the dialog // to show the full text of a comment function expandFullComment(){ $(this).parent().children('.ctcm-body').show() $(this).hide() } // Apply comment tag filters // For a given comment, say whether it // should be shown given the current context function commentMatches(comment, type, user, site, tags){ if (comment.types && !comment.types[type]) return false if (comment.users && !comment.users[user]) return false if (comment.sites && !comment.sites[site]) return false if (comment.tags){ var hasTag = false for(var i=0; tags && i<tags.length; i++){ if (comment.tags[tags[i]]) hasTag=true } if(!hasTag) return false } return true } // User clicked "Save" when editing the list of comment templates function doneEditing(){ comments = parseComments($(this).prev('textarea').val()) storeComments() closeMenu() } // Show the edit comment dialog function editComments(){ // Pointless to edit comments that will just get overwritten // If there is a URL, only allow the URL to be edited if(GM_getValue(storageKeys.url)) return urlConf() ctcmi.html( "<pre># Comment title\n"+ "Comment body\n"+ "types: "+typeMapInput.replace(/,/g, ", ")+"\n"+ "users: "+userMapInput.replace(/,/g, ", ")+"\n"+ "sites: stackoverflow, physics, meta.stackoverflow, physics.meta, etc\n"+ "tags: javascript, python, etc</pre>"+ "<p>Limiting by types, users, sites, and tags is optional.</p>" ) ctcmi.append($('<textarea>').val(exportComments())) ctcmi.append($('<button>Save</Button>').click(doneEditing)) ctcmi.append($('<button>Cancel</Button>').click(closeMenu)) ctcmi.append($('<button>From URL...</Button>').click(urlConf)) return false } function getAuthorNode(postNode){ return postNode.find('.post-signature .user-details[itemprop="author"]') } var opNode function getOpNode(){ if (!opNode) opNode = getAuthorNode($('#question,.question')) return opNode } function getUserNodeId(node){ if (!node) return "-" var link = node.find('a') if (!link.length) return "-" var href = link.attr('href') if (!href) return "-" return href.replace(/[^0-9]+/g, "") } var opId function getOpId(){ if (!opId) opId = getUserNodeId(getOpNode()) return opId } function getUserNodeName(node){ if (!node) return "-" var link = node.find('a') if (!link.length) return "-" // Remove spaces from user names so that they can be used in @name references return link.text().replace(/ /,"") } var opName function getOpName(){ if (!opName) opName = getUserNodeName(getOpNode()) return opName } function getUserNodeRep(node){ if (!node) return "-" var r = node.find('.reputation-score') if (!r.length) return "-" return r.text() } var opRep function getOpRep(){ if (!opRep) opRep = getUserNodeRep(getOpNode()) return opRep } function getPostNode(){ return commentTextField.parents('#question,.question,.answer') } function getPostAuthorNode(){ return getAuthorNode(getPostNode()) } function getAuthorId(){ return getUserNodeId(getPostAuthorNode()) } function getAuthorName(){ return getUserNodeName(getPostAuthorNode()) } function getAuthorRep(){ return getUserNodeRep(getPostAuthorNode()) } function getPostId(){ var postNode = getPostNode(); if (!postNode.length) return "-" if (postNode.attr('data-questionid')) return postNode.attr('data-questionid') if (postNode.attr('data-answerid')) return postNode.attr('data-answerid') return "-" } // Map of variables to functions that return their replacements var varMap = { 'SITENAME': getSiteName, 'SITEURL': getSiteUrl, 'MYUSERID': getMyUserId, 'QUESTIONID': getQuestionId, 'OPID': getOpId, 'OPNAME': getOpName, 'OPREP': getOpRep, 'POSTID': getPostId, 'AUTHORID': getAuthorId, 'AUTHORNAME': getAuthorName, 'AUTHORREP': getAuthorRep } // Build regex to find variables from keys of map var varRegex = new RegExp('\\$('+Object.keys(varMap).join('|')+')\\$?', 'g') function fillVariables(s){ // Perform the variable replacement return s.replace(varRegex, function (m) { // Remove $ before looking up in map return varMap[m.replace(/\$/g,"")]() }); } // Show the URL configuration dialog function urlConf(){ var url = GM_getValue(storageKeys.url) ctcmi.html( "<p>Comments will be loaded from this URL when saved and once a day afterwards. Github raw URLs have been whitelisted. Other URLs will ask for your permission.</p>" ) if (url) ctcmi.append("<p>Remove the URL to be able to edit the comments in your browser.</p>") else ctcmi.append("<p>Using a URL will <b>overwrite</b> any edits to the comments you have made.</p>") ctcmi.append($('<input type=text placeholder=https://raw.githubusercontent.com/user/repo/123/stack-exchange-comments.txt>').val(url)) ctcmi.append($('<button>Save</Button>').click(doneUrlConf)) ctcmi.append($('<button>Cancel</Button>').click(closeMenu)) return false } // User clicked "Save" in URL configuration dialog function doneUrlConf(){ GM_setValue(storageKeys.url, ($(this).prev('input').val())) // Force a load by removing the timestamp of the last load GM_deleteValue(storageKeys.lastUpdate) loadStorageUrlComments() closeMenu() } // Look up the URL from local storage, fetch the URL // and parse the comment templates from it // unless it has already been done recently function loadStorageUrlComments(){ var url = GM_getValue(storageKeys.url) if (!url) return var lu = GM_getValue(storageKeys.lastUpdate); if (lu && lu > Date.now() - 8600000) return loadComments(url) } // Hook into clicks for the entire page that should show a context menu // Only handle the clicks on comment input areas (don't prevent // the context menu from appearing in other places.) $(document).contextmenu(function(e){ var target = $(e.target) if (target.is('.comments-link')){ // The "Add a comment" link var parent = target.parents('.answer,#question,.question') // Show the comment text area target.trigger('click') // Bring up the context menu for it showMenu(parent.find('textarea')) e.preventDefault() return false } else if (target.is('.js-flag-post-link')){ // the "Flag" link for a question or answer // Click it to show pop up target.trigger('click') // Wait for the popup setTimeout(function(){ $('input[value="PostOther"]').trigger('click') },100) setTimeout(function(){ showMenu($('input[value="PostOther"]').parents('label').find('textarea')) },200) e.preventDefault() return false } else if (target.parents('.js-comment-flag').length){ // The flag icon next to a comment target.trigger('click') setTimeout(function(){ // Click "Something else" $('#comment-flag-type-CommentOther').prop('checked',true).parents('.js-comment-flag-option').find('.js-required-comment').removeClass('d-none') },100) setTimeout(function(){ showMenu($('#comment-flag-type-CommentOther').parents('.js-comment-flag-option').find('textarea')) },200) e.preventDefault() return false } else if (target.is('.js-close-question-link')){ // The "Close" link for a question target.trigger('click') setTimeout(function(){ $('#closeReasonId-SiteSpecific').trigger('click') },100) setTimeout(function(){ $('#siteSpecificCloseReasonId-other').trigger('click') },200) setTimeout(function(){ showMenu($('#siteSpecificCloseReasonId-other').parents('.js-popup-radio-action').find('textarea')) },300) e.preventDefault() return false } else if (target.is('textarea,input[type="text"]') && (!target.val() || target.val() == target[0].defaultValue)){ // A text field that is blank or hasn't been modified var type = getType(target) //console.log("Type: " + type) if (type){ // A text field for entering a comment showMenu(target) e.preventDefault() return false } } }) console.log(fillVariables("Question ID: $QUESTIONID, Site name: $SITENAME$, Site URL: $SITEURL, My user ID: $MYUSERID")) console.log(fillVariables("OP id: $OPID, OP name: $OPNAME, OP rep: $OPREP")) function showMenu(target){ commentTextField=target console.log(fillVariables("Post id: $POSTID, Author id: $AUTHORID, Author name: $AUTHORNAME, Author rep: $AUTHORREP")) var type = getType(target) var user = getUserClass() var site = getSite() var tags = getTags() ctcmi.html("") for (var i=0; i<comments.length; i++){ if(commentMatches(comments[i], type, user, site, tags)){ ctcmi.append( $('<div class=ctcm-comment>').append( $('<span class=ctcm-expand>\u25bc</span>').click(expandFullComment) ).append( $('<h4 class=ctcm-title>').text(comments[i].title).click(insertComment) ).append( $('<div class=ctcm-body>').text(fillVariables(comments[i].comment)).click(insertComment) ) ) } } ctcmi.append($('<button>Edit</Button>').click(editComments)) ctcmi.append($('<button>Cancel</Button>').click(closeMenu)) if (target.parents('.popup,#modal-base').length) target.after(ctcmo) else $(document.body).append(ctcmo) ctcmo.show() } function closeMenu(){ ctcmo.hide() ctcmo.remove() } // Hook into clicks anywhere in the document // and listen for ones that related to our dialog $(document).click(function(e){ if(ctcmo.is(':visible')){ // dialog is open if($(e.target).parents('#ctcm-back').length == 0) { // click wasn't on the dialog itself closeMenu() } // Clicks when the dialog are open belong to us, // prevent other things from happening e.preventDefault() return false } }) })();