Stack Exchange comment template context menu

Adds a context menu (right click, long press, command click, etc) to comment boxes on Stack Exchange with customizable pre-written responses.

当前为 2022-05-28 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Stack Exchange comment template context menu
  3. // @namespace http://ostermiller.org/
  4. // @version 1.13
  5. // @description Adds a context menu (right click, long press, command click, etc) to comment boxes on Stack Exchange with customizable pre-written responses.
  6. // @match https://*.stackexchange.com/questions/*
  7. // @match https://*.stackexchange.com/review/*
  8. // @match https://*.stackoverflow.com/*questions/*
  9. // @match https://*.stackoverflow.com/review/*
  10. // @match https://*.askubuntu.com/questions/*
  11. // @match https://*.askubuntu.com/review/*
  12. // @match https://*.superuser.com/questions/*
  13. // @match https://*.superuser.com/review/*
  14. // @match https://*.serverfault.com/questions/*
  15. // @match https://*.serverfault.com/review/*
  16. // @match https://*.mathoverflow.net/questions/*
  17. // @match https://*.mathoverflow.net/review/*
  18. // @match https://*.stackapps.com/questions/*
  19. // @match https://*.stackapps.com/review/*
  20. // @connect raw.githubusercontent.com
  21. // @connect *
  22. // @grant GM_addStyle
  23. // @grant GM_setValue
  24. // @grant GM_getValue
  25. // @grant GM_deleteValue
  26. // @grant GM_xmlhttpRequest
  27. // ==/UserScript==
  28. (function() {
  29. 'use strict'
  30.  
  31. // Access to JavaScript variables from the Stack Exchange site
  32. var $ = unsafeWindow.jQuery
  33.  
  34. // eg. physics.stackexchange.com -> physics
  35. function validateSite(s){
  36. var m = /^((?:meta\.)?[a-z0-9]+(?:\.meta)?)\.?[a-z0-9\.]*$/.exec(s.toLowerCase().trim().replace(/^(https?:\/\/)?(www\.)?/,""))
  37. if (!m) return null
  38. return m[1]
  39. }
  40.  
  41. function validateTag(s){
  42. return s.toLowerCase().trim().replace(/ +/g,"-")
  43. }
  44.  
  45. // eg hello-world, hello-worlds, hello world, hello worlds, and hw all map to hello-world
  46. function makeFilterMap(s){
  47. var m = {}
  48. s=s.split(/,/)
  49. for (var i=0; i<s.length; i++){
  50. // original
  51. m[s[i]] = s[i]
  52. // plural
  53. m[s[i]+"s"] = s[i]
  54. // with spaces
  55. m[s[i].replace(/-/g," ")] = s[i]
  56. // plural with spaces
  57. m[s[i].replace(/-/g," ")+"s"] = s[i]
  58. // abbreviation
  59. m[s[i].replace(/([a-z])[a-z]+(-|$)/g,"$1")] = s[i]
  60. }
  61. return m
  62. }
  63.  
  64. var userMapInput = "moderator,user"
  65. var userMap = makeFilterMap(userMapInput)
  66. function validateUser(s){
  67. return userMap[s.toLowerCase().trim()]
  68. }
  69.  
  70. var typeMapInput = "question,answer,edit-question,edit-answer,close-question,flag-comment,flag-question,flag-answer,decline-flag,helpful-flag,reject-edit"
  71. var typeMap = makeFilterMap(typeMapInput)
  72. typeMap.c = 'close-question'
  73. typeMap.close = 'close-question'
  74.  
  75. function loadComments(urls){
  76. loadCommentsRecursive([], urls.split(/[\r\n ]+/))
  77. }
  78.  
  79. function loadCommentsRecursive(aComments, aUrls){
  80. if (!aUrls.length) {
  81. if (aComments.length){
  82. comments = aComments
  83. storeComments()
  84. if(GM_getValue(storageKeys.url)){
  85. GM_setValue(storageKeys.lastUpdate, Date.now())
  86. }
  87. }
  88. return
  89. }
  90. var url = aUrls.pop()
  91. if (!url){
  92. loadCommentsRecursive(aComments, aUrls)
  93. return
  94. }
  95. console.log("Loading comments from " + url)
  96. GM_xmlhttpRequest({
  97. method: "GET",
  98. url: url,
  99. onload: function(r){
  100. var lComments = parseComments(r.responseText)
  101. if (!lComments || !lComments.length){
  102. alert("No comment templates loaded from " + url)
  103. } else {
  104. aComments = aComments.concat(lComments)
  105. }
  106. loadCommentsRecursive(aComments, aUrls)
  107. },
  108. onerror: function(){
  109. alert("Could not load comment templates from " + url)
  110. loadCommentsRecursive(aComments, aUrls)
  111. }
  112. })
  113. }
  114.  
  115. function validateType(s){
  116. return typeMap[s.toLowerCase().trim()]
  117. }
  118.  
  119. // Map of functions that clean up the filter-tags on comment templates
  120. var tagValidators = {
  121. tags: validateTag,
  122. sites: validateSite,
  123. users: validateUser,
  124. types: validateType
  125. }
  126.  
  127. var attributeValidators = {
  128. socvr: trim
  129. }
  130.  
  131. function trim(s){
  132. return s.trim()
  133. }
  134.  
  135. // Given a filter tag name and an array of filter tag values,
  136. // clean up and canonicalize each of them
  137. // Put them into a hash set (map each to true) for performant lookups
  138. function validateAllTagValues(tag, arr){
  139. var ret = {}
  140. for (var i=0; i<arr.length; i++){
  141. // look up the validation function for the filter tag type and call it
  142. var v = tagValidators[tag](arr[i])
  143. // Put it in the hash set
  144. if (v) ret[v]=1
  145. }
  146. if (Object.keys(ret).length) return ret
  147. return null
  148. }
  149.  
  150. function validateValues(tag, value){
  151. if (tag in tagValidators) return validateAllTagValues(tag, value.split(/,/))
  152. if (tag in attributeValidators) return attributeValidators[tag](value)
  153. return null
  154. }
  155.  
  156. // List of keys used for storage, centralized for multiple usages
  157. var storageKeys = {
  158. comments: "ctcm-comments",
  159. url: "ctcm-url",
  160. lastUpdate: "ctcm-last-update"
  161. }
  162.  
  163. // On-load, parse comment templates from local storage
  164. var comments = parseComments(GM_getValue(storageKeys.comments))
  165. // The download comment templates from URL if configured
  166. if(GM_getValue(storageKeys.url)){
  167. loadStorageUrlComments()
  168. } else if (!comments || !comments.length){
  169. // If there are NO comments, fetch the defaults
  170. loadComments("https://raw.githubusercontent.com/stephenostermiller/stack-exchange-comment-templates/master/default-templates.txt")
  171. }
  172.  
  173. function hasCommentWarn(){
  174. return checkCommentLengths().length > 0
  175. }
  176.  
  177. function commentWarnHtml(){
  178. var problems = checkCommentLengths()
  179. if (!problems.length) return $('<span>')
  180. var s = $("<ul>")
  181. for (var i=0; i<problems.length; i++){
  182. s.append($('<li>').text("⚠️ " + problems[i]))
  183. }
  184. return $('<div>').append($('<h3>').text("Problems")).append(s)
  185. }
  186.  
  187. function checkCommentLengths(){
  188. var problems = []
  189. for (var i=0; i<comments.length; i++){
  190. var c = comments[i]
  191. var length = c.comment.length;
  192. if (length > 600){
  193. problems.push("Comment template is too long (" + length + "/600): " + c.title)
  194. } else if (length > 500 && (!c.types || c.types['flag-question'] || c.types['flag-answer'])){
  195. problems.push("Comment template is too long for flagging posts (" + length + "/500): " + c.title)
  196. } else if (length > 300 && (!c.types || c.types['edit-question'] || c.types['edit-answer'])){
  197. problems.push("Comment template is too long for an edit (" + length + "/300): " + c.title)
  198. } else if (length > 200 && (!c.types || c.types['decline-flag'] || c.types['helpful-flag'])){
  199. problems.push("Comment template is too long for flag handling (" + length + "/200): " + c.title)
  200. } else if (length > 200 && (!c.types || c.types['flag-comment'])){
  201. problems.push("Comment template is too long for flagging comments (" + length + "/200): " + c.title)
  202. }
  203. }
  204. return problems
  205. }
  206.  
  207. // Serialize the comment templates into local storage
  208. function storeComments(){
  209. if (!comments || !comments.length) GM_deleteValue(storageKeys.comments)
  210. else GM_setValue(storageKeys.comments, exportComments())
  211. }
  212.  
  213. function parseJsonpComments(s){
  214. var cs = []
  215. var callback = function(o){
  216. for (var i=0; i<o.length; i++){
  217. var c = {
  218. title: o[i].name,
  219. comment: o[i].description
  220. }
  221. var m = /^(?:\[([A-Z,]+)\])\s*(.*)$/.exec(c.title);
  222. if (m){
  223. c.title=m[2]
  224. c.types=validateValues("types",m[1])
  225. }
  226. if (c && c.title && c.comment) cs.push(c)
  227. }
  228. }
  229. eval(s)
  230. return cs
  231. }
  232.  
  233. function parseComments(s){
  234. if (!s) return []
  235. if (s.startsWith("callback(")) return parseJsonpComments(s)
  236. var lines = s.split(/\n|\r|\r\n/)
  237. var c, m, cs = []
  238. for (var i=0; i<lines.length; i++){
  239. var line = lines[i].trim()
  240. if (!line){
  241. // Blank line indicates end of comment
  242. if (c && c.title && c.comment) cs.push(c)
  243. c=null
  244. } else {
  245. // Comment template title
  246. // Starts with #
  247. // May contain type filter tag abbreviations (for compat with SE-AutoReviewComments)
  248. // eg # Comment title
  249. // eg ### [Q,A] Comment title
  250. m = /^#+\s*(?:\[([A-Z,]+)\])?\s*(.*)$/.exec(line);
  251. if (m){
  252. // Stash previous comment if it wasn't already ended by a new line
  253. if (c && c.title && c.comment) cs.push(c)
  254. // Start a new comment with title
  255. c={title:m[2]}
  256. // Handle type filter tags if they exist
  257. if (m[1]) c.types=validateValues("types",m[1])
  258. } else if (c) {
  259. // Already started parsing a comment, look for filter tags and comment body
  260. m = /^(sites|types|users|tags|socvr)\:\s*(.*)$/.exec(line);
  261. if (m){
  262. // Add filter tags
  263. c[m[1]]=validateValues(m[1],m[2])
  264. } else {
  265. // Comment body (join multiple lines with spaces)
  266. if (c.comment) c.comment=c.comment+" "+line
  267. else c.comment=line
  268. }
  269. } else {
  270. // No comment started, didn't find a comment title
  271. console.log("Could not parse line from comment templates: " + line)
  272. }
  273. }
  274. }
  275. // Stash the last comment if it isn't followed by a new line
  276. if (c && c.title && c.comment) cs.push(c)
  277. return cs
  278. }
  279.  
  280. function sort(arr){
  281. if (!(arr instanceof Array)) arr = Object.keys(arr)
  282. arr.sort()
  283. return arr
  284. }
  285.  
  286. function exportComments(){
  287. var s ="";
  288. for (var i=0; i<comments.length; i++){
  289. var c = comments[i]
  290. s += "# " + c.title + "\n"
  291. s += c.comment + "\n"
  292. if (c.types) s += "types: " + sort(c.types).join(", ") + "\n"
  293. if (c.sites) s += "sites: " + sort(c.sites).join(", ") + "\n"
  294. if (c.users) s += "users: " + sort(c.users).join(", ") + "\n"
  295. if (c.tags) s += "tags: " + sort(c.tags).join(", ") + "\n"
  296. if (c.socvr) s += "socvr: " + c.socvr + "\n"
  297. s += "\n"
  298. }
  299. return s;
  300. }
  301.  
  302. // inner lightbox content area
  303. var ctcmi = $('<div id=ctcm-menu>')
  304. // outer translucent lightbox background that covers the whole page
  305. var ctcmo = $('<div id=ctcm-back>').append(ctcmi)
  306. 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)}")
  307. GM_addStyle("#ctcm-menu{z-index:999999;min-width:320px;position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background:var(--white);border:5px solid var(--theme-header-foreground-color);padding:1em;max-width:100vw;max-height:100vh;overflow:auto}")
  308. GM_addStyle(".ctcm-body{display:none;background:var(--black-050);padding:.3em;cursor: pointer;")
  309. GM_addStyle(".ctcm-expand{float:right;cursor: pointer;}")
  310. GM_addStyle(".ctcm-title{margin-top:.3em;cursor: pointer;}")
  311. GM_addStyle("#ctcm-menu textarea{width:90vw;min-width:300px;max-width:1000px;height:60vh;resize:both;display:block}")
  312. GM_addStyle("#ctcm-menu input[type='text']{width:90vw;min-width:300px;max-width:1000px;display:block}")
  313. GM_addStyle("#ctcm-menu button{margin-top:1em;margin-right:.5em}")
  314. GM_addStyle("#ctcm-menu button.right{float:right}")
  315. GM_addStyle("#ctcm-menu h3{margin:.5em auto;font-size: 150%;}")
  316.  
  317. // Node input: text field where content can be written.
  318. // Used for filter tags to know which comment templates to show in which contexts.
  319. // Also used for knowing which clicks should show the context menu,
  320. // if a type isn't returned by this method, no menu will show up
  321. function getType(node){
  322. var prefix = "";
  323.  
  324. // Most of these rules use properties of the node or the node's parents
  325. // to deduce their context
  326.  
  327. if (node.is('.js-rejection-reason-custom')) return "reject-edit"
  328. if (node.parents('.js-comment-flag-option').length) return "flag-comment"
  329. if (node.parents('.js-flagged-post').length){
  330. if (/decline/.exec(node.attr('placeholder'))) return "decline-flag"
  331. else return "helpful-flag"
  332. }
  333.  
  334. if (node.parents('.site-specific-pane').length) prefix = "close-"
  335. else if (node.parents('.mod-attention-subform').length) prefix = "flag-"
  336. else if (node.is('.edit-comment,#edit-comment')) prefix = "edit-"
  337. else if(node.is('.js-comment-text-input')) prefix = ""
  338. else return null
  339.  
  340. if (node.parents('#question,.question').length) return prefix + "question"
  341. if (node.parents('#answers,.answer').length) return prefix + "answer"
  342.  
  343. // Fallback for single post edit page
  344. if (node.parents('.post-form').find('h2:last').text()=='Question') return prefix + "question"
  345. if (node.parents('.post-form').find('h2:last').text()=='Answer') return prefix + "answer"
  346.  
  347. return null
  348. }
  349.  
  350. // Mostly moderator or non-moderator (user.)
  351. // Not-logged in and low rep users are not able to comment much
  352. // and are unlikely to use this tool, no need to identify them
  353. // and give them special behavior.
  354. // Maybe add a class for staff in the future?
  355. var userclass
  356. function getUserClass(){
  357. if (!userclass){
  358. if ($('.js-mod-inbox-button').length) userclass="moderator"
  359. else if ($('.s-topbar--item.s-user-card').length) userclass="user"
  360. else userclass="anonymous"
  361. }
  362. return userclass
  363. }
  364.  
  365. // The Stack Exchange site this is run on (just the subdomain, eg "stackoverflow")
  366. var site
  367. function getSite(){
  368. if(!site) site=validateSite(location.hostname)
  369. return site
  370. }
  371.  
  372. // Which tags are on the question currently being viewed
  373. var tags
  374. function getTags(){
  375. if(!tags) tags=$.map($('.post-taglist .post-tag'),function(tag){return $(tag).text()})
  376. return tags
  377. }
  378.  
  379. // The id of the question currently being viewed
  380. function getQuestionId(){
  381. var id = $('.question').attr('data-questionid')
  382. if (!id){
  383. var l = $('.answer-hyperlink')
  384. if (l.length) id=l.attr('href').replace(/^\/questions\/([0-9]+).*/,"$1")
  385. }
  386. if (!id) id="-"
  387. return id
  388. }
  389.  
  390. // The human readable name of the current Stack Exchange site
  391. function getSiteName(){
  392. return $('meta[property="og:site_name"]').attr('content').replace(/ ?Stack Exchange/, "")
  393. }
  394.  
  395. // The Stack Exchange user id for the person using this tool
  396. function getMyUserId() {
  397. return $('a.s-topbar--item.s-user-card').attr('href').replace(/^\/users\/([0-9]+)\/.*/,"$1")
  398. }
  399.  
  400. // The Stack Exchange user name for the person using this tool
  401. function getMyName() {
  402. return $('.s-avatar').attr('title').replace(/ /g,"")
  403. }
  404.  
  405. // The full host name of the Stack Exchange site
  406. function getSiteUrl(){
  407. return location.hostname
  408. }
  409.  
  410. // Store the comment text field that was clicked on
  411. // so that it can be filled with the comment template
  412. var commentTextField
  413.  
  414. // Insert the comment template into the text field
  415. // called when a template is clicked in the dialog box
  416. // so "this" refers to the clicked item
  417. function insertComment(){
  418. // The comment to insert is stored in a div
  419. // near the item that was clicked
  420. var body = $(this).parent().children('.ctcm-body')
  421. var socvr = body.attr('data-socvr')
  422. if (socvr){
  423. var url = "//" + getSiteUrl() + "/questions/" + getQuestionId()
  424. var title = $('h1').first().text()
  425. title = new Option(title).innerHTML
  426. $('#content').prepend($(`<div style="border:5px solid blue;padding:.7em;margin:.5em 0"><a target=_blank href=//chat.stackoverflow.com/rooms/41570/so-close-vote-reviewers>SOCVR: </a><div>[tag:cv-pls] ${socvr} [${title}](${url})</div></div>`))
  427. }
  428. var cmt = body.text()
  429.  
  430. // Put in the comment
  431. commentTextField.val(cmt).focus()
  432.  
  433. // highlight place for additional input,
  434. // if specified in the template
  435. var typeHere="[type here]"
  436. var typeHereInd = cmt.indexOf(typeHere)
  437. if (typeHereInd >= 0) commentTextField[0].setSelectionRange(typeHereInd, typeHereInd + typeHere.length)
  438.  
  439. closeMenu()
  440. }
  441.  
  442. // User clicked on the expand icon in the dialog
  443. // to show the full text of a comment
  444. function expandFullComment(){
  445. $(this).parent().children('.ctcm-body').show()
  446. $(this).hide()
  447. }
  448.  
  449. // Apply comment tag filters
  450. // For a given comment, say whether it
  451. // should be shown given the current context
  452. function commentMatches(comment, type, user, site, tags){
  453. if (comment.types && !comment.types[type]) return false
  454. if (comment.users && !comment.users[user]) return false
  455. if (comment.sites && !comment.sites[site]) return false
  456. if (comment.tags){
  457. var hasTag = false
  458. for(var i=0; tags && i<tags.length; i++){
  459. if (comment.tags[tags[i]]) hasTag=true
  460. }
  461. if(!hasTag) return false
  462. }
  463. return true
  464. }
  465.  
  466. // User clicked "Save" when editing the list of comment templates
  467. function doneEditing(){
  468. comments = parseComments($(this).prev('textarea').val())
  469. storeComments()
  470. closeMenu()
  471. }
  472.  
  473. // Show the edit comment dialog
  474. function editComments(){
  475. // Pointless to edit comments that will just get overwritten
  476. // If there is a URL, only allow the URL to be edited
  477. if(GM_getValue(storageKeys.url)) return urlConf()
  478. ctcmi.html(
  479. "<pre># Comment title\n"+
  480. "Comment body\n"+
  481. "types: "+typeMapInput.replace(/,/g, ", ")+"\n"+
  482. "users: "+userMapInput.replace(/,/g, ", ")+"\n"+
  483. "sites: stackoverflow, physics, meta.stackoverflow, physics.meta, etc\n"+
  484. "tags: javascript, python, etc\n"+
  485. "socvr: Message for Stack Overflow close vote reviews chat</pre>"+
  486. "<p>types, users, sites, tags, and socvr are optional.</p>"
  487. )
  488. .append($('<textarea>').val(exportComments()))
  489. .append($('<button>Save</button>').click(doneEditing))
  490. .append($('<button>Cancel</button>').click(closeMenu))
  491. .append($('<button>From URL...</button>').click(urlConf))
  492. return false
  493. }
  494.  
  495. // Show info
  496. function showInfo(){
  497. ctcmi.html(
  498. "<div><h2><a target=_blank href=//github.com/stephenostermiller/stack-exchange-comment-templates>Stack Exchange Comment Templates Context Menu</a></h2></div>"
  499. )
  500. .append(commentWarnHtml())
  501. .append(htmlVars())
  502. .append($('<button>Cancel</button>').click(closeMenu))
  503. return false
  504. }
  505. function getAuthorNode(postNode){
  506. return postNode.find('.post-signature .user-details[itemprop="author"]')
  507. }
  508.  
  509. function getOpNode(){
  510. return getAuthorNode($('#question,.question'))
  511. }
  512.  
  513. function getUserNodeId(node){
  514. if (!node) return "-"
  515. var link = node.find('a')
  516. if (!link.length) return "-"
  517. var href = link.attr('href')
  518. if (!href) return "-"
  519. return href.replace(/[^0-9]+/g, "")
  520. }
  521.  
  522. function getOpId(){
  523. return getUserNodeId(getOpNode())
  524. }
  525.  
  526. function getUserNodeName(node){
  527. if (!node) return "-"
  528. var link = node.find('a')
  529. if (!link.length) return "-"
  530. // Remove spaces from user names so that they can be used in @name references
  531. return link.text().replace(/ /g,"")
  532. }
  533.  
  534. function getOpName(){
  535. return getUserNodeName(getOpNode())
  536. }
  537.  
  538. function getUserNodeRep(node){
  539. if (!node) return "-"
  540. var r = node.find('.reputation-score')
  541. if (!r.length) return "-"
  542. return r.text()
  543. }
  544.  
  545. function getOpRep(){
  546. return getUserNodeRep(getOpNode())
  547. }
  548.  
  549. function getPostNode(){
  550. return commentTextField.parents('#question,.question,.answer')
  551. }
  552.  
  553. function getPostAuthorNode(){
  554. return getAuthorNode(getPostNode())
  555. }
  556.  
  557. function getAuthorId(){
  558. return getUserNodeId(getPostAuthorNode())
  559. }
  560.  
  561. function getAuthorName(){
  562. return getUserNodeName(getPostAuthorNode())
  563. }
  564.  
  565. function getAuthorRep(){
  566. return getUserNodeRep(getPostAuthorNode())
  567. }
  568.  
  569. function getPostId(){
  570. var postNode = getPostNode();
  571. if (!postNode.length) return "-"
  572. if (postNode.attr('data-questionid')) return postNode.attr('data-questionid')
  573. if (postNode.attr('data-answerid')) return postNode.attr('data-answerid')
  574. return "-"
  575. }
  576.  
  577. // Map of variables to functions that return their replacements
  578. var varMap = {
  579. 'SITENAME': getSiteName,
  580. 'SITEURL': getSiteUrl,
  581. 'MYUSERID': getMyUserId,
  582. 'MYNAME': getMyName,
  583. 'QUESTIONID': getQuestionId,
  584. 'OPID': getOpId,
  585. 'OPNAME': getOpName,
  586. 'OPREP': getOpRep,
  587. 'POSTID': getPostId,
  588. 'AUTHORID': getAuthorId,
  589. 'AUTHORNAME': getAuthorName,
  590. 'AUTHORREP': getAuthorRep
  591. }
  592.  
  593. // Cache variables so they don't have to be looked up for every single question
  594. var varCache={}
  595.  
  596. function getCachedVar(key){
  597. if (!varCache[key]) varCache[key] = varMap[key]()
  598. return varCache[key]
  599. }
  600.  
  601. function hasVarWarn(){
  602. var varnames = Object.keys(varMap)
  603. for (var i=0; i<varnames.length; i++){
  604. if (getCachedVar(varnames[i]).match(/^-?$/)) return true
  605. }
  606. return false
  607. }
  608.  
  609. function htmlVars(){
  610. var n = $("<ul>")
  611. var varnames = Object.keys(varMap)
  612. for (var i=0; i<varnames.length; i++){
  613. var li=$("<li>")
  614. var val = getCachedVar(varnames[i])
  615. if (val.match(/^-?$/)) li.append($("<span>").text("⚠️ "))
  616. li.append($("<b>").text(varnames[i])).append($("<span>").text(": ")).append($("<span>").text(val))
  617. n.append(li)
  618. }
  619. return $('<div>').append($('<h3>').text("Variables")).append(n)
  620. }
  621.  
  622. // Build regex to find variables from keys of map
  623. var varRegex = new RegExp('\\$('+Object.keys(varMap).join('|')+')\\$?', 'g')
  624. function fillVariables(s){
  625. // Perform the variable replacement
  626. return s.replace(varRegex, function (m) {
  627. // Remove $ from variable name
  628. return getCachedVar(m.replace(/\$/g,""))
  629. });
  630. }
  631.  
  632. // Show the URL configuration dialog
  633. function urlConf(){
  634. var url = GM_getValue(storageKeys.url)
  635. ctcmi.html(
  636. "<p>Comments will be loaded from these URLs when saved and once a day afterwards. Multiple URLs can be specified, each on its own line. Github raw URLs have been whitelisted. Other URLs will ask for your permission.</p>"
  637. )
  638. if (url) ctcmi.append("<p>Remove all the URLs to be able to edit the comments in your browser.</p>")
  639. else ctcmi.append("<p>Using a URL will <b>overwrite</b> any edits to the comments you have made.</p>")
  640. ctcmi.append($('<textarea placeholder=https://raw.githubusercontent.com/user/repo/123/stack-exchange-comments.txt>').val(url))
  641. ctcmi.append($('<button>Save</button>').click(doneUrlConf))
  642. ctcmi.append($('<button>Cancel</button>').click(closeMenu))
  643. return false
  644. }
  645.  
  646. // User clicked "Save" in URL configuration dialog
  647. function doneUrlConf(){
  648. GM_setValue(storageKeys.url, $(this).prev('textarea').val())
  649. // Force a load by removing the timestamp of the last load
  650. GM_deleteValue(storageKeys.lastUpdate)
  651. loadStorageUrlComments()
  652. closeMenu()
  653. }
  654.  
  655. // Look up the URL from local storage, fetch the URL
  656. // and parse the comment templates from it
  657. // unless it has already been done recently
  658. function loadStorageUrlComments(){
  659. var url = GM_getValue(storageKeys.url)
  660. if (!url) return
  661. var lu = GM_getValue(storageKeys.lastUpdate);
  662. if (lu && lu > Date.now() - 8600000) return
  663. loadComments(url)
  664. }
  665.  
  666. // Hook into clicks for the entire page that should show a context menu
  667. // Only handle the clicks on comment input areas (don't prevent
  668. // the context menu from appearing in other places.)
  669. $(document).contextmenu(function(e){
  670. var target = $(e.target)
  671. if (target.is('.comments-link')){
  672. // The "Add a comment" link
  673. var parent = target.parents('.answer,#question,.question')
  674. // Show the comment text area
  675. target.trigger('click')
  676. // Bring up the context menu for it
  677. showMenu(parent.find('textarea'))
  678. e.preventDefault()
  679. return false
  680. } else if (target.closest('#review-action-Reject,label[for="review-action-Reject"]').length){
  681. // Suggested edit review queue - reject
  682. target.trigger('click')
  683. $('button.js-review-submit').trigger('click')
  684. setTimeout(function(){
  685. // Click "causes harm"
  686. $('#rejection-reason-0').trigger('click')
  687. },100)
  688. setTimeout(function(){
  689. showMenu($('#rejection-reason-0').parents('.flex--item').find('textarea'))
  690. },200)
  691. e.preventDefault()
  692. return false
  693. } else if (target.closest('#review-action-Unsalvageable,label[for="review-action-Unsalvageable"]').length){
  694. // Triage review queue - unsalvageable
  695. target.trigger('click')
  696. $('button.js-review-submit').trigger('click')
  697. showMenuInFlagDialog()
  698. e.preventDefault()
  699. return false
  700. } else if (target.is('.js-flag-post-link')){
  701. // the "Flag" link for a question or answer
  702. // Click it to show pop up
  703. target.trigger('click')
  704. showMenuInFlagDialog()
  705. e.preventDefault()
  706. return false
  707. } else if (target.closest('.js-comment-flag').length){
  708. // The flag icon next to a comment
  709. target.trigger('click')
  710. setTimeout(function(){
  711. // Click "Something else"
  712. $('#comment-flag-type-CommentOther').prop('checked',true).parents('.js-comment-flag-option').find('.js-required-comment').removeClass('d-none')
  713. },100)
  714. setTimeout(function(){
  715. showMenu($('#comment-flag-type-CommentOther').parents('.js-comment-flag-option').find('textarea'))
  716. },200)
  717. e.preventDefault()
  718. return false
  719. } else if (target.closest('#review-action-Close,label[for="review-action-Close"],#review-action-NeedsAuthorEdit,label[for="review-action-NeedsAuthorEdit"]').length){
  720. // Close votes review queue - close action
  721. // or Triage review queue - needs author edit action
  722. target.trigger('click')
  723. $('button.js-review-submit').trigger('click')
  724. showMenuInCloseDialog()
  725. e.preventDefault()
  726. return false
  727. } else if (target.is('.js-close-question-link')){
  728. // The "Close" link for a question
  729. target.trigger('click')
  730. showMenuInCloseDialog()
  731. e.preventDefault()
  732. return false
  733. } else if (target.is('textarea,input[type="text"]') && (!target.val() || target.val() == target[0].defaultValue)){
  734. // A text field that is blank or hasn't been modified
  735. var type = getType(target)
  736. if (type){
  737. // A text field for entering a comment
  738. showMenu(target)
  739. e.preventDefault()
  740. return false
  741. }
  742. }
  743. })
  744.  
  745. function showMenuInFlagDialog(){
  746. // Wait for the popup
  747. setTimeout(function(){
  748. $('input[value="PostOther"]').trigger('click')
  749. },100)
  750. setTimeout(function(){
  751. showMenu($('input[value="PostOther"]').parents('label').find('textarea'))
  752. },200)
  753. }
  754.  
  755. function showMenuInCloseDialog(){
  756. setTimeout(function(){
  757. $('#closeReasonId-SiteSpecific').trigger('click')
  758. },100)
  759. setTimeout(function(){
  760. $('#siteSpecificCloseReasonId-other').trigger('click')
  761. },200)
  762. setTimeout(function(){
  763. showMenu($('#siteSpecificCloseReasonId-other').parents('.js-popup-radio-action').find('textarea'))
  764. },300)
  765. }
  766.  
  767. function filterComments(e){
  768. if (e.key === "Enter") {
  769. // Pressing enter in the comment filter
  770. // should insert the first visible comment
  771. insertComment.call($('.ctcm-title:visible').first())
  772. e.preventDefault()
  773. return false
  774. }
  775. if (e.key == "Escape"){
  776. closeMenu()
  777. e.preventDefault()
  778. return false
  779. }
  780. // Show comments that contain the filter (case-insensitive)
  781. var f = $(this).val().toLowerCase()
  782. $('.ctcm-comment').each(function(){
  783. var c = $(this).text().toLowerCase()
  784. $(this).toggle(c.includes(f))
  785. })
  786. }
  787.  
  788. function showMenu(target){
  789. varCache={} // Clear the variable cache
  790. commentTextField=target
  791. var type = getType(target)
  792. var user = getUserClass()
  793. var site = getSite()
  794. var tags = getTags()
  795. ctcmi.html("")
  796. var filter=$('<input type=text placeholder="filter... (type then press enter to insert the first comment)">').keyup(filterComments).change(filterComments)
  797. ctcmi.append(filter)
  798. for (var i=0; i<comments.length; i++){
  799. if(commentMatches(comments[i], type, user, site, tags)){
  800. ctcmi.append(
  801. $('<div class=ctcm-comment>').append(
  802. $('<span class=ctcm-expand>\u25bc</span>').click(expandFullComment)
  803. ).append(
  804. $('<h4 class=ctcm-title>').text(comments[i].title).click(insertComment)
  805. ).append(
  806. $('<div class=ctcm-body>').text(fillVariables(comments[i].comment)).click(insertComment).attr('data-socvr',comments[i].socvr||"")
  807. )
  808. )
  809. }
  810. }
  811. var info = (hasVarWarn()||hasCommentWarn())?"⚠️":"ⓘ"
  812. ctcmi.append($('<button>Edit</button>').click(editComments))
  813. ctcmi.append($('<button>Cancel</button>').click(closeMenu))
  814. ctcmi.append($('<button class=right>').text(info).click(showInfo))
  815. target.parents('.popup,#modal-base,body').first().append(ctcmo)
  816. ctcmo.show()
  817. filter.focus()
  818. }
  819.  
  820. function closeMenu(){
  821. ctcmo.hide()
  822. ctcmo.remove()
  823. }
  824.  
  825. // Hook into clicks anywhere in the document
  826. // and listen for ones that related to our dialog
  827. $(document).click(function(e){
  828. // dialog is open
  829. if(ctcmo.is(':visible')){
  830. // Allow clicks on links in the dialog to have default behavior
  831. if($(e.target).is('a')) return true
  832. // click wasn't on the dialog itself
  833. if(!$(e.target).parents('#ctcm-back').length) closeMenu()
  834. // Clicks when the dialog are open belong to us,
  835. // prevent other things from happening
  836. e.preventDefault()
  837. return false
  838. }
  839. })
  840. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址