AO3: Comment Formatting and Preview

Adds buttons to insert HTML formatting, and shows a live preview box of what the comment will look like

当前为 2024-03-31 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name AO3: Comment Formatting and Preview
  3. // @namespace https://greasyfork.org/en/users/906106-escctrl
  4. // @version 2.2
  5. // @description Adds buttons to insert HTML formatting, and shows a live preview box of what the comment will look like
  6. // @author escctrl
  7. // @license MIT
  8. // @match *://*.archiveofourown.org/tags/*/comments*
  9. // @match *://*.archiveofourown.org/users/*/inbox*
  10. // @match *://*.archiveofourown.org/works/*
  11. // @match *://archiveofourown.org/collections/*/works/*
  12. // @match *://*.archiveofourown.org/comments/*
  13. // @match *://*.archiveofourown.org/comments?*
  14. // @match *://*.archiveofourown.org/admin_posts/*
  15. // @exclude *://archiveofourown.org/works/*/new
  16. // @exclude *://archiveofourown.org/works/*/edit*
  17. // @exclude *://archiveofourown.org/works/new*
  18. // @exclude *://archiveofourown.org/works/search*
  19. // @grant none
  20. // @require https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js
  21. // @require https://ajax.googleapis.com/ajax/libs/jqueryui/1.13.2/jquery-ui.min.js
  22. // @require https://cdnjs.cloudflare.com/ajax/libs/jqueryui-touch-punch/0.2.3/jquery.ui.touch-punch.min.js
  23. // ==/UserScript==
  24.  
  25. (function($) {
  26. 'use strict';
  27.  
  28. /*********************************************************
  29. GUI CONFIGURATION
  30. *********************************************************/
  31.  
  32. // load storage on page startup
  33. var standardmap = new Map(JSON.parse(localStorage.getItem('cmtfmtstandard'))); // only a key: true/false list
  34. var custommap = new Map(JSON.parse(localStorage.getItem('cmtfmtcustom'))); // all content we need from user to display & insert what they want
  35.  
  36. // if the background is dark, use the dark UI theme to match
  37. let dialogtheme = lightOrDark($('body').css('background-color')) == "dark" ? "ui-darkness" : "base";
  38.  
  39. // the config dialog container
  40. let cfg = document.createElement('div');
  41. cfg.id = 'cmtFmtDialog';
  42.  
  43. // adding the jQuery stylesheet to style the dialog, and fixing the interferance of AO3's styling
  44. $("head").append(`<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/${dialogtheme}/jquery-ui.css">`)
  45. .prepend(`<script src="https://use.fontawesome.com/ed555db3cc.js" />`)
  46. .append(`<style tyle="text/css">#${cfg.id}, .ui-dialog .ui-dialog-buttonpane button {font-size: revert; line-height: 1.286;}
  47. #${cfg.id} form {box-shadow: revert; cursor:auto;}
  48. #${cfg.id} #custombutton a {cursor:pointer;}
  49. #${cfg.id} legend {font-size: inherit; height: auto; width: auto; opacity: inherit;}
  50. #${cfg.id} fieldset {background: revert; box-shadow: revert;}
  51. #${cfg.id} input[type='text'] { position: relative; top: 1px; padding: .4em; width: 3em; }
  52. #${cfg.id} ul { padding-left: 2em; }
  53. #${cfg.id} ul li { list-style: circle; }
  54. #${cfg.id} #stdbutton label { font-family: FontAwesome, sans-serif; }
  55. #${cfg.id} #custombutton div button { width: 0.5em; }
  56. #${cfg.id} #custombutton div input:nth-of-type(1) { width: 2em; }
  57. #${cfg.id} #custombutton div input:nth-of-type(2) { width: 6em; }
  58. #${cfg.id} #custombutton div input:nth-of-type(3) { width: 10em; }
  59. #${cfg.id} #custombutton div input:nth-of-type(4) { width: 10em; }
  60. </style>`);
  61.  
  62. // the available standard buttons, display & insert stuff
  63. let config_std = new Map([
  64. ["bold", { icon: "&#xf032;", text: "Bold", ins_pre: "<b>", ins_app: "</b>" }],
  65. ["italic", { icon: "&#xf033;", text: "Italic", ins_pre: "<em>", ins_app: "</em>" }],
  66. ["underline", { icon: "&#xf0cd;", text: "Underline", ins_pre: "<u>", ins_app: "</u>" }],
  67. ["strike", { icon: "&#xf0cc;", text: "Strikethrough", ins_pre: "<s>", ins_app: "</s>" }],
  68. ["link", { icon: "&#xf0c1;", text: "Link", ins_pre: "<a href=\"\">", ins_app: "</a>" }],
  69. ["image", { icon: "&#xf03e;", text: "Image", ins_pre: "<img src=\"", ins_app: "\" />" }],
  70. ["quote", { icon: "&#xf10d;", text: "Quote", ins_pre: "<blockquote>", ins_app: "</blockquote>" }],
  71. ["paragraph", { icon: "&#xf1dd;", text: "Paragraph", ins_pre: "<p>", ins_app: "</p>" }],
  72. ["listnum", { icon: "&#xf0cb;", text: "Numbered List", ins_pre: "<ol><li>", ins_app: "</li></ol>" }],
  73. ["listbull", { icon: "&#xf0ca;", text: "Bullet List", ins_pre: "<ul><li>", ins_app: "</li></ul>" }],
  74. ["listitem", { icon: "&#xf192;", text: "List Item", ins_pre: "<li>", ins_app: "</li>" }],
  75. ]);
  76.  
  77. // build GUI for chosen enable/disable of standard buttons
  78. let standardbuttons = '';
  79. config_std.forEach((val, key) => {
  80. standardbuttons += `<label for="${key}" title="${val.text}">${val.icon}</label><input type="checkbox" name="${key}" id="${key}" ${(standardmap.get(key)==="true" || standardmap.size == 0) ? 'checked="checked"' : ""}>`;
  81. });
  82.  
  83. // reformat the stored custom buttons to match the standard
  84. let config_custom = new Map();
  85. custommap.forEach((val, key) => {
  86. val = JSON.parse(val); // turn the string into an array of 4x2 each
  87. let newval = {}; // turn the array into an object
  88. val.forEach((v) => {
  89. newval[v[0]] = v[1];
  90. });
  91. config_custom.set(key, newval);
  92. });
  93.  
  94. // build GUI for stored custom buttons
  95. let custombuttons = '';
  96. config_custom.forEach((val) => {
  97. custombuttons += `<div><button class="remove">-</button><input type="text" name="icon" value="${val.icon}"><input type="text" name="text" value="${val.text}">
  98. <input type="text" name="ins_pre" value="${val.ins_pre}"><input type="text" name="ins_app" value="${val.ins_app}"></div>`;
  99. });
  100.  
  101. // template for a blank row to add a custom button (is cloned before inserting into DOM)
  102. let newcustombutton = `<div><button class="remove">-</button><input type="text" name="icon" value="Icon"><input type="text" name="text" value="Title">
  103. <input type="text" name="ins_pre" value="Insert Before"><input type="text" name="ins_app" value="Insert After"></div>`;
  104.  
  105. $(cfg).html(`<form>
  106. <fieldset id='stdbutton'>
  107. <legend>Standard text formatting</legend>
  108. <p>Select the buttons you'd like to see as options on the button bar.</p>
  109. ${standardbuttons}
  110. </fieldset>
  111. <fieldset id='custombutton'>
  112. <legend>Custom HTML or text</legend>
  113. <p>Define custom buttons, which will insert HTML and/or text.</p>
  114. <ul><li>In the first field, choose <a href="https://fontawesome.com/v4/icons/">the Icon</a> you want on the button.<br />
  115. Copy its 4-letter Unicode (for example "f004" for the heart) into this field.</li>
  116. <li>If you leave the Icon field empty, the Title from the second field is shown on the button instead. The Title also appears as mouseover text.</li>
  117. <li>Put the text you want inserted around the cursor position into the Insert Before and Insert After fields.</li></ul>
  118. ${custombuttons}
  119. <div><button class="add">+</button></div>
  120. </fieldset>
  121. <p>Any changes only apply after reloading the page.</p>
  122. </form>`);
  123.  
  124. // attach it to the DOM so that selections work (but only if #main exists, else it might be a Retry Later error page)
  125. if ($("#main").length == 1) $("body").append(cfg);
  126.  
  127. // turn checkboxes and radiobuttons into pretty buttons
  128. $( "#cmtFmtDialog input[type='checkbox'], #cmtFmtDialog input[type='radio']" ).checkboxradio({ icon: false });
  129.  
  130. // optimizing the size of the GUI in case it's a mobile device
  131. let dialogwidth = parseInt($("body").css("width")); // parseInt ignores letters (px)
  132. dialogwidth = dialogwidth > 550 ? 550 : dialogwidth * 0.9;
  133.  
  134. // initialize the dialog (but don't open it)
  135. $( "#cmtFmtDialog" ).dialog({
  136. appendTo: "#main",
  137. modal: true,
  138. title: 'Comment Formatting Buttons',
  139. draggable: true,
  140. resizable: false,
  141. autoOpen: false,
  142. width: dialogwidth,
  143. position: {my:"center", at: "center top"},
  144. buttons: {
  145. Reset: deleteConfig,
  146. Save: storeConfig,
  147. Cancel: function() { $( "#cmtFmtDialog" ).dialog( "close" ); }
  148. }
  149. });
  150.  
  151. // event triggers if form is submitted with the <enter> key
  152. $( "#cmtFmtDialog form" ).on("submit", (e) => {
  153. e.preventDefault();
  154. storeConfig();
  155. });
  156.  
  157. // putting event triggers on buttons that will delete custom rows
  158. function evRemoveRow(el) {
  159. $(el).on("click", (e) => {
  160. e.cancelBubble = true;
  161. e.preventDefault();
  162. $(e.target).parent().remove(); // delete whole div
  163. });
  164. }
  165. // run it immediately on the stored custom buttons
  166. evRemoveRow($( "#cmtFmtDialog button.remove" ));
  167.  
  168. // putting event trigger on button that will add blank custom rows
  169. $( "#cmtFmtDialog button.add" ).on("click", (e) => {
  170. e.cancelBubble = true;
  171. e.preventDefault();
  172. // add a new blank row and attach the remove event again
  173. $(e.target).parent().before( $(newcustombutton).clone() );
  174. evRemoveRow($( "#cmtFmtDialog button.remove:last-of-type" ));
  175. });
  176.  
  177. function deleteConfig() {
  178. // deselects all buttons, empties all fields in the form
  179. $('#cmtFmtDialog form').trigger("reset");
  180. $('#cmtFmtDialog button.remove').trigger("click");
  181.  
  182. // deletes the localStorage
  183. localStorage.removeItem('cmtfmtstandard');
  184. localStorage.removeItem('cmtfmtcustom');
  185.  
  186. $( "#cmtFmtDialog" ).dialog( "close" );
  187. }
  188.  
  189. function storeConfig() {
  190. // build a Map() for enabled standard buttons => button -> true/false
  191. let storestd = new Map();
  192. $( "#cmtFmtDialog #stdbutton [name]" ).each(function() { storestd.set( $(this).prop('name'), String($(this).prop('checked')) ); });
  193. localStorage.setItem('cmtfmtstandard', JSON.stringify(Array.from(storestd.entries())));
  194.  
  195. // build a Map() for the custom buttons => custom# -> { icon: X, text: X, ins_pre: X, ins_app: X }
  196. let storecustom = new Map();
  197. $( "#cmtFmtDialog #custombutton div:has(input)" ).each((i, div) => {
  198. let parts = new Map();
  199. $(div).find('[name]').each(function() { parts.set( $(this).prop('name'), $(this).prop('value') ); });
  200. storecustom.set('custom'+i, JSON.stringify(Array.from(parts.entries())));
  201. });
  202. localStorage.setItem('cmtfmtcustom', JSON.stringify(Array.from(storecustom.entries())));
  203.  
  204. $( "#cmtFmtDialog" ).dialog( "close" );
  205. }
  206.  
  207. /* CREATING THE LINK TO OPEN THE CONFIGURATION DIALOG */
  208.  
  209. // if no other script has created it yet, write out a "Userscripts" option to the main navigation
  210. if ($('#scriptconfig').length == 0) {
  211. $('#header ul.primary.navigation li.dropdown').last()
  212. .after(`<li class="dropdown" id="scriptconfig">
  213. <a class="dropdown-toggle" href="/" data-toggle="dropdown" data-target="#">Userscripts</a>
  214. <ul class="menu dropdown-menu"></ul></li>`);
  215. }
  216. // then add this script's config option to navigation dropdown
  217. $('#scriptconfig .dropdown-menu').append(`<li><a href="javascript:void(0);" id="opencfg_cmtfmt">Comment Formatting Buttons</a></li>`);
  218.  
  219. // on click, open the configuration dialog
  220. $("#opencfg_cmtfmt").on("click", function(e) {
  221. $( "#cmtFmtDialog" ).dialog('open');
  222. });
  223.  
  224. /*********************************************************
  225. COMMENT BAR AND PREVIEW FUNCTIONALITY
  226. *********************************************************/
  227.  
  228. // merge the enabled standard and custom buttons into one list
  229. let config = new Map();
  230. config_std.forEach((val, key) => { if (standardmap.get(key)==="true" || standardmap.size == 0) config.set(key, val); });
  231. config_custom.forEach((val, key) => {
  232. if (val.icon !== "") val.icon = `&#x${val.icon};`; // add what Font Awesome needs to display properly
  233. config.set(key, val);
  234. });
  235.  
  236. $("head").append(`<style type="text/css"> ul.comment-format { font-family: FontAwesome, sans-serif; float: left; }
  237. ul.comment-format a { cursor: default; }
  238. ul.comment-format .fontawe { font-family: FontAwesome, sans-serif; }
  239. div.comment-preview.userstuff { border: 1px inset #f0f0f0; min-height: 1em; padding: 0.2em 1em; line-height: 1.5; } </style>`);
  240.  
  241. // collate the button bar
  242. let buttonBar = document.createElement('ul');
  243. $(buttonBar).addClass('actions comment-format');
  244. for (let c of config) {
  245. let li = document.createElement('li');
  246. li.title = c[1].text;
  247. li.innerHTML = `<a class="${c[0]}">${ (c[1].icon === "") ? c[1].text : c[1].icon}</a>`;
  248. if (c[1].icon !== "") $(li).addClass("fontawe");
  249. $(buttonBar).append(li);
  250. }
  251. $(buttonBar).find('a').on('click', function(e) {
  252. e.cancelBubble = true;
  253. e.preventDefault();
  254. insert_format(e.target);
  255. });
  256.  
  257. // preview box template (will be cloned when inserting into DOM)
  258. let preview = `<div class='comment-preview userstuff' title='Comment Preview (approximate)'></div>`;
  259.  
  260. // click event function called with the button <a> that was clicked (so we know which textarea to insert it to)
  261. function insert_format(elm) {
  262. let area = $(elm).parent().parent().next('textarea')[0]; // the textarea element we're dealing with
  263. let text = $(area).val(); // the original content of the comment box
  264. let cursor_start = area.selectionStart, cursor_end = area.selectionEnd; // any highlighted text
  265. let fmt = config.get(elm.className); // grab the formatting HTML corresponding to the clicked button
  266.  
  267. // set the comment box text with the new content, and focus back on it
  268. $(area).val(
  269. text.slice(0, cursor_start) + // text from before cursor position or highlight
  270. fmt.ins_pre + text.slice(cursor_start, cursor_end) + fmt.ins_app + // wrap any highlighted text in the formatting HTML
  271. text.slice(cursor_end) // text from after cursor position or highlight
  272. ).focus();
  273.  
  274. // set the cursor position to the same value so we don't highlight anymore
  275. let cursor_new =
  276. // if we only inserted format HTML, set it between the halves so you can enter the text to format
  277. (cursor_start == cursor_end) ? cursor_start + fmt.ins_pre.length :
  278. // if we highlighted, and this is a link (so the link text is already done), set the cursor into the href=""
  279. (elm.className == "link") ? cursor_start + fmt.ins_pre.length - 2 :
  280. // otherwise always set it at the end of the inserted text i.e. the same distance from the end as originally
  281. $(area).val().length - (text.length - cursor_end);
  282. area.selectionStart = area.selectionEnd = cursor_new;
  283.  
  284. // manually trigger the value-has-changed event so the preview updates (not calling update_preview directly as it would fail on Sticky Comment Box)
  285. $(area).trigger('input');
  286. }
  287.  
  288. // function called when anything changes (input event trigger) in the textarea
  289. function update_preview(elm) {
  290. let prevbox = $(elm).siblings('div.comment-preview')[0];
  291. prevbox.innerHTML = parse_preview($(elm).val());
  292. }
  293.  
  294. // adding the button bar & preview box for any visible comment area (clone with events!)
  295. $('textarea[id^="comment_content_for"]')
  296. .before($(buttonBar).clone(true, true))
  297. .after($(preview).clone())
  298. .on('input', function(e) { update_preview(e.target); })
  299. .each(function() { update_preview(this); }); // update the preview for reloaded pages with cached comment text
  300.  
  301. // Support for Sticky Comment Box!
  302. // if this script executes first, we may have to wait for the Sticky Comment Box to appear in the DOM
  303. if ($('#float_cmt_dlg').length == 0) {
  304. const observer = new MutationObserver(function(mutList, obs) {
  305. for (const mut of mutList) { for (const node of mut.addedNodes) {
  306. // check if the added node is our comment box
  307. if (node.id == 'float_cmt_dlg') {
  308. obs.disconnect(); // stop listening immediately, we have what we needed
  309. // add the buttonbar to the Sticky Comment Box (it doesn't get a preview field to save space)
  310. $('#float_cmt_userinput textarea').before($(buttonBar).clone(true, true).css('font-size', '80%'));
  311. }
  312. }}
  313. });
  314.  
  315. // listening to as few changes as possible: only direct children of <body>
  316. observer.observe($('body').get(0), { attributes: false, childList: true, subtree: false });
  317.  
  318. // failsafe: stop listening after 5 seconds (in case the other script isn't running)
  319. // this will always execute even if the box was already found and the observer disconnected previously
  320. let timeout = setTimeout(() => {
  321. observer.disconnect();
  322. }, 5 * 1000);
  323. }
  324. // when the Sticky Comment Box script executed first and the textarea is already there, we immediately add the button bar
  325. else $('#float_cmt_userinput textarea').before($(buttonBar).clone(true, true).css('font-size', '80%'));
  326.  
  327. // adding the bar for any loaded comment areas
  328. // global AJAX listener but we're only interested in the calls that add the comment reply box
  329. XMLHttpRequest.prototype.getResponseHeader = function() { // jQuery ajaxSuccess method doesn't catch the reply pages
  330. if (!(this.readyState == 4 && this.status == 200)) return true;
  331. var xhrurl = this.responseURL;
  332. var params = (new URL(xhrurl)).searchParams;
  333.  
  334. // When replying to comments (on work or tag page)
  335. if (xhrurl.indexOf("comments/add_comment_reply?") !== -1) {
  336. $('textarea#comment_content_for_'+params.get("id"))
  337. .before($(buttonBar).clone(true, true))
  338. .after($(preview).clone())
  339. .on('input', function(e) { update_preview(e.target); });
  340. }
  341.  
  342. // When replying to inbox comments (floating box)
  343. else if (xhrurl.indexOf("inbox/reply?") !== -1) {
  344. $('textarea#comment_content_for_'+params.get("comment_id"))
  345. .before($(buttonBar).clone(true, true))
  346. .after($(preview).clone())
  347. .on('input', function(e) { update_preview(e.target); });
  348. }
  349.  
  350. // When editing a comment
  351. else if (xhrurl.indexOf("/comments/") !== -1 && xhrurl.indexOf("/edit") !== -1) {
  352. let commentid = xhrurl.match(/\d+/);
  353. $('li#comment_'+commentid[0]+' textarea[id^=comment_content_for_]')
  354. .before($(buttonBar).clone(true, true))
  355. .after($(preview).clone())
  356. .on('input', function(e) { update_preview(e.target); })
  357. .each(function() { update_preview(this); }); // update the preview with the existing comment text
  358. }
  359. };
  360.  
  361. function parse_preview(content) {
  362. // if the comment box is still empty, show a simple placeholder
  363. if (content == "") return "<p><i>preview</i></p>";
  364.  
  365. // if there is comment text, turn double linebreaks into paragraphs and single linebreaks into <br>
  366. // linebreak compatibility
  367. const lbr = (content.indexOf("\r\n") > -1) ? "\r\n" :
  368. (content.indexOf("\r") > -1) ? "\r" : "\n";
  369.  
  370. // remove obvious issues: whitespaces between <li>'s, a <br> plus linebreak (while editing)
  371. content = content.replace(/<\/li>\W+<li>/ig, '</li><li>');
  372. content = content.replace(/<br \/>(\r\n|\r|\n)/ig, '<br />');
  373.  
  374. content = content.split(`${lbr}${lbr}`); // split content at each two linebreaks in a row
  375. const regexLine = new RegExp(`${lbr}`, "g");
  376. content.forEach((v, i) => {
  377. v = v.replace(regexLine, "<br />"); // a single linebreak is replaced by a <br>
  378. content[i] = "<p>"+v.trim()+"</p>"; // two linebreaks are wrapped in a <p>
  379. });
  380. return content.join(lbr);
  381. }
  382.  
  383. })(jQuery);
  384.  
  385. // helper function to determine whether a color (the background in use) is light or dark
  386. // https://awik.io/determine-color-bright-dark-using-javascript/
  387. function lightOrDark(color) {
  388. var r, g, b, hsp;
  389. if (color.match(/^rgb/)) { color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/);
  390. r = color[1]; g = color[2]; b = color[3]; }
  391. else { color = +("0x" + color.slice(1).replace(color.length < 5 && /./g, '$&$&'));
  392. r = color >> 16; g = color >> 8 & 255; b = color & 255; }
  393. hsp = Math.sqrt( 0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b) );
  394. if (hsp>127.5) { return 'light'; } else { return 'dark'; }
  395. }