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.15 // @description Adds a context menu (right click, long press, command click, etc) to comment boxes on Stack Exchange with customizable pre-written responses. // @match https://*.stackexchange.com/questions/* // @match https://*.stackexchange.com/review/* // @match https://*.stackexchange.com/admin/* // @match https://*.stackoverflow.com/*questions/* // @match https://*.stackoverflow.com/review/* // @match https://*.stackoverflow.com/admin/* // @match https://*.askubuntu.com/questions/* // @match https://*.askubuntu.com/review/* // @match https://*.askubuntu.com/admin/* // @match https://*.superuser.com/questions/* // @match https://*.superuser.com/review/* // @match https://*.superuser.com/admin/* // @match https://*.serverfault.com/questions/* // @match https://*.serverfault.com/review/* // @match https://*.serverfault.com/admin/* // @match https://*.mathoverflow.net/questions/* // @match https://*.mathoverflow.net/review/* // @match https://*.mathoverflow.net/admin/* // @match https://*.stackapps.com/questions/* // @match https://*.stackapps.com/review/* // @match https://*.stackapps.com/admin/* // @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 // 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(/ +/g,"-") } // 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,reject-edit" var typeMap = makeFilterMap(typeMapInput) typeMap.c = 'close-question' typeMap.close = 'close-question' function loadComments(urls){ loadCommentsRecursive([], urls.split(/[\r\n ]+/)) } function loadCommentsRecursive(aComments, aUrls){ if (!aUrls.length) { if (aComments.length){ comments = aComments storeComments() if(GM_getValue(storageKeys.url)){ GM_setValue(storageKeys.lastUpdate, Date.now()) } } return } var url = aUrls.pop() if (!url){ loadCommentsRecursive(aComments, aUrls) return } console.log("Loading comments from " + url) GM_xmlhttpRequest({ method: "GET", url: url, onload: function(r){ var lComments = parseComments(r.responseText) if (!lComments || !lComments.length){ alert("No comment templates loaded from " + url) } else { aComments = aComments.concat(lComments) } loadCommentsRecursive(aComments, aUrls) }, onerror: function(){ alert("Could not load comment templates from " + url) loadCommentsRecursive(aComments, aUrls) } }) } 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 } var attributeValidators = { socvr: trim } function trim(s){ return s.trim() } // 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 } function validateValues(tag, value){ if (tag in tagValidators) return validateAllTagValues(tag, value.split(/,/)) if (tag in attributeValidators) return attributeValidators[tag](value) 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://raw.githubusercontent.com/stephenostermiller/stack-exchange-comment-templates/master/default-templates.txt") } function hasCommentWarn(){ return checkCommentLengths().length > 0 } function commentWarnHtml(){ var problems = checkCommentLengths() if (!problems.length) return $('<span>') var s = $("<ul>") for (var i=0; i<problems.length; i++){ s.append($('<li>').text("⚠️ " + problems[i])) } return $('<div>').append($('<h3>').text("Problems")).append(s) } function checkCommentLengths(){ var problems = [] for (var i=0; i<comments.length; i++){ var c = comments[i] var length = c.comment.length; if (length > 600){ problems.push("Comment template is too long (" + length + "/600): " + c.title) } else if (length > 500 && (!c.types || c.types['flag-question'] || c.types['flag-answer'])){ problems.push("Comment template is too long for flagging posts (" + length + "/500): " + c.title) } else if (length > 300 && (!c.types || c.types['edit-question'] || c.types['edit-answer'])){ problems.push("Comment template is too long for an edit (" + length + "/300): " + c.title) } else if (length > 200 && (!c.types || c.types['decline-flag'] || c.types['helpful-flag'])){ problems.push("Comment template is too long for flag handling (" + length + "/200): " + c.title) } else if (length > 200 && (!c.types || c.types['flag-comment'])){ problems.push("Comment template is too long for flagging comments (" + length + "/200): " + c.title) } } return problems } // Serialize the comment templates into local storage function storeComments(){ if (!comments || !comments.length) GM_deleteValue(storageKeys.comments) else GM_setValue(storageKeys.comments, exportComments()) } function parseJsonpComments(s){ var cs = [] var callback = function(o){ for (var i=0; i<o.length; i++){ var c = { title: o[i].name, comment: o[i].description } var m = /^(?:\[([A-Z,]+)\])\s*(.*)$/.exec(c.title); if (m){ c.title=m[2] c.types=validateValues("types",m[1]) } if (c && c.title && c.comment) cs.push(c) } } eval(s) return cs } function parseComments(s){ if (!s) return [] if (s.startsWith("callback(")) return parseJsonpComments(s) 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] Comment 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=validateValues("types",m[1]) } else if (c) { // Already started parsing a comment, look for filter tags and comment body m = /^(sites|types|users|tags|socvr)\:\s*(.*)$/.exec(line); if (m){ // Add filter tags c[m[1]]=validateValues(m[1],m[2]) } 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" if (c.socvr) s += "socvr: " + c.socvr + "\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:var(--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:var(--black-050);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}") GM_addStyle("#ctcm-menu button.right{float:right}") GM_addStyle("#ctcm-menu h3{margin:.5em auto;font-size: 150%;}") // 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.is('.js-rejection-reason-custom')) return "reject-edit" 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 ($('.s-topbar--item.s-user-card').length) userclass="user" else userclass="anonymous" } return userclass } // The Stack Exchange site this is run on (just the subdomain, 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 function getQuestionId(){ var id = $('.question').attr('data-questionid') if (!id){ var l = $('.answer-hyperlink') if (l.length) id=l.attr('href').replace(/^\/questions\/([0-9]+).*/,"$1") } if (!id) id="-" return id } // The human readable name of the current Stack Exchange site function getSiteName(){ return $('meta[property="og:site_name"]').attr('content').replace(/ ?Stack Exchange/, "") } // The Stack Exchange user id for the person using this tool function getMyUserId() { return $('a.s-topbar--item.s-user-card').attr('href').replace(/^\/users\/([0-9]+)\/.*/,"$1") } // The Stack Exchange user name for the person using this tool function getMyName() { var n=$('header .s-avatar[title]').attr('title') if (!n) return "-" return n.replace(/ /g,"") } // The full host name of the Stack Exchange site function getSiteUrl(){ return location.hostname } // 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 body = $(this).parent().children('.ctcm-body') var socvr = body.attr('data-socvr') if (socvr){ var url = "//" + getSiteUrl() + "/questions/" + getQuestionId() var title = $('h1').first().text() title = new Option(title).innerHTML $('#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>`)) } var cmt = 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\n"+ "socvr: Message for Stack Overflow close vote reviews chat</pre>"+ "<p>types, users, sites, tags, and socvr are optional.</p>" ) .append($('<textarea>').val(exportComments())) .append($('<button>Save</button>').click(doneEditing)) .append($('<button>Cancel</button>').click(closeMenu)) .append($('<button>From URL...</button>').click(urlConf)) return false } // Show info function showInfo(){ ctcmi.html( "<div><h2><a target=_blank href=//github.com/stephenostermiller/stack-exchange-comment-templates>Stack Exchange Comment Templates Context Menu</a></h2></div>" ) .append(commentWarnHtml()) .append(htmlVars()) .append($('<button>Cancel</button>').click(closeMenu)) return false } function getAuthorNode(postNode){ return postNode.find('.post-signature .user-details[itemprop="author"]') } function getOpNode(){ return getAuthorNode($('#question,.question')) } 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, "") } function getOpId(){ return getUserNodeId(getOpNode()) } 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(/ /g,"") } function getOpName(){ return getUserNodeName(getOpNode()) } function getUserNodeRep(node){ if (!node) return "-" var r = node.find('.reputation-score') if (!r.length) return "-" return r.text() } function getOpRep(){ return getUserNodeRep(getOpNode()) } 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, 'MYNAME': getMyName, 'QUESTIONID': getQuestionId, 'OPID': getOpId, 'OPNAME': getOpName, 'OPREP': getOpRep, 'POSTID': getPostId, 'AUTHORID': getAuthorId, 'AUTHORNAME': getAuthorName, 'AUTHORREP': getAuthorRep } // Cache variables so they don't have to be looked up for every single question var varCache={} function getCachedVar(key){ if (!varCache[key]) varCache[key] = varMap[key]() return varCache[key] } function hasVarWarn(){ var varnames = Object.keys(varMap) for (var i=0; i<varnames.length; i++){ if (getCachedVar(varnames[i]).match(/^-?$/)) return true } return false } function htmlVars(){ var n = $("<ul>") var varnames = Object.keys(varMap) for (var i=0; i<varnames.length; i++){ var li=$("<li>") var val = getCachedVar(varnames[i]) if (val.match(/^-?$/)) li.append($("<span>").text("⚠️ ")) li.append($("<b>").text(varnames[i])).append($("<span>").text(": ")).append($("<span>").text(val)) n.append(li) } return $('<div>').append($('<h3>').text("Variables")).append(n) } // 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 $ from variable name return getCachedVar(m.replace(/\$/g,"")) }); } // Show the URL configuration dialog function urlConf(){ var url = GM_getValue(storageKeys.url) ctcmi.html( "<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>" ) if (url) ctcmi.append("<p>Remove all the URLs 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($('<textarea 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('textarea').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.closest('#review-action-Reject,label[for="review-action-Reject"]').length){ // Suggested edit review queue - reject target.trigger('click') $('button.js-review-submit').trigger('click') setTimeout(function(){ // Click "causes harm" $('#rejection-reason-0').trigger('click') },100) setTimeout(function(){ showMenu($('#rejection-reason-0').parents('.flex--item').find('textarea')) },200) e.preventDefault() return false } else if (target.closest('#review-action-Unsalvageable,label[for="review-action-Unsalvageable"]').length){ // Triage review queue - unsalvageable target.trigger('click') $('button.js-review-submit').trigger('click') showMenuInFlagDialog() 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') showMenuInFlagDialog() e.preventDefault() return false } else if (target.closest('.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.closest('#review-action-Close,label[for="review-action-Close"],#review-action-NeedsAuthorEdit,label[for="review-action-NeedsAuthorEdit"]').length){ // Close votes review queue - close action // or Triage review queue - needs author edit action target.trigger('click') $('button.js-review-submit').trigger('click') showMenuInCloseDialog() e.preventDefault() return false } else if (target.is('.js-close-question-link')){ // The "Close" link for a question target.trigger('click') showMenuInCloseDialog() 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) if (type){ // A text field for entering a comment showMenu(target) e.preventDefault() return false } } }) function showMenuInFlagDialog(){ // Wait for the popup setTimeout(function(){ $('input[value="PostOther"]').trigger('click') },100) setTimeout(function(){ showMenu($('input[value="PostOther"]').parents('label').find('textarea')) },200) } function showMenuInCloseDialog(){ 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) } function filterComments(e){ if (e.key === "Enter") { // Pressing enter in the comment filter // should insert the first visible comment insertComment.call($('.ctcm-title:visible').first()) e.preventDefault() return false } if (e.key == "Escape"){ closeMenu() e.preventDefault() return false } // Show comments that contain the filter (case-insensitive) var f = $(this).val().toLowerCase() $('.ctcm-comment').each(function(){ var c = $(this).text().toLowerCase() $(this).toggle(c.includes(f)) }) } function showMenu(target){ varCache={} // Clear the variable cache commentTextField=target var type = getType(target) var user = getUserClass() var site = getSite() var tags = getTags() ctcmi.html("") var filter=$('<input type=text placeholder="filter... (type then press enter to insert the first comment)">').keyup(filterComments).change(filterComments) ctcmi.append(filter) 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).attr('data-socvr',comments[i].socvr||"") ) ) } } var info = (hasVarWarn()||hasCommentWarn())?"⚠️":"ⓘ" ctcmi.append($('<button>Edit</button>').click(editComments)) ctcmi.append($('<button>Cancel</button>').click(closeMenu)) ctcmi.append($('<button class=right>').text(info).click(showInfo)) target.parents('.popup,#modal-base,body').first().append(ctcmo) ctcmo.show() filter.focus() } 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){ // dialog is open if(ctcmo.is(':visible')){ // Allow clicks on links in the dialog to have default behavior if($(e.target).is('a')) return true // click wasn't on the dialog itself if(!$(e.target).parents('#ctcm-back').length) closeMenu() // Clicks when the dialog are open belong to us, // prevent other things from happening e.preventDefault() return false } }) })();