您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds buttons to insert HTML formatting, and shows a live preview box of what the comment will look like
当前为
// ==UserScript== // @name AO3: Comment Formatting and Preview // @namespace https://greasyfork.org/en/users/906106-escctrl // @version 2.3 // @description Adds buttons to insert HTML formatting, and shows a live preview box of what the comment will look like // @author escctrl // @license MIT // @match *://*.archiveofourown.org/tags/*/comments* // @match *://*.archiveofourown.org/users/*/inbox* // @match *://*.archiveofourown.org/works/* // @match *://*.archiveofourown.org/chapters/* // @match *://archiveofourown.org/collections/*/works/* // @match *://*.archiveofourown.org/comments/* // @match *://*.archiveofourown.org/comments?* // @match *://*.archiveofourown.org/admin_posts/* // @exclude *://archiveofourown.org/works/*/new // @exclude *://archiveofourown.org/works/*/edit* // @exclude *://archiveofourown.org/works/new* // @exclude *://archiveofourown.org/works/search* // @grant none // @require https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js // @require https://ajax.googleapis.com/ajax/libs/jqueryui/1.13.2/jquery-ui.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/jqueryui-touch-punch/0.2.3/jquery.ui.touch-punch.min.js // ==/UserScript== (function($) { 'use strict'; /********************************************************* GUI CONFIGURATION *********************************************************/ // load storage on page startup var standardmap = new Map(JSON.parse(localStorage.getItem('cmtfmtstandard'))); // only a key: true/false list var custommap = new Map(JSON.parse(localStorage.getItem('cmtfmtcustom'))); // all content we need from user to display & insert what they want // if the background is dark, use the dark UI theme to match let dialogtheme = lightOrDark($('body').css('background-color')) == "dark" ? "ui-darkness" : "base"; // the config dialog container let cfg = document.createElement('div'); cfg.id = 'cmtFmtDialog'; // adding the jQuery stylesheet to style the dialog, and fixing the interferance of AO3's styling $("head").append(`<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/${dialogtheme}/jquery-ui.css">`) .prepend(`<script src="https://use.fontawesome.com/ed555db3cc.js" />`) .append(`<style tyle="text/css">#${cfg.id}, .ui-dialog .ui-dialog-buttonpane button {font-size: revert; line-height: 1.286;} #${cfg.id} form {box-shadow: revert; cursor:auto;} #${cfg.id} #custombutton a {cursor:pointer;} #${cfg.id} legend {font-size: inherit; height: auto; width: auto; opacity: inherit;} #${cfg.id} fieldset {background: revert; box-shadow: revert;} #${cfg.id} input[type='text'] { position: relative; top: 1px; padding: .4em; width: 3em; } #${cfg.id} ul { padding-left: 2em; } #${cfg.id} ul li { list-style: circle; } #${cfg.id} #stdbutton label { font-family: FontAwesome, sans-serif; } #${cfg.id} #custombutton div button { width: 0.5em; } #${cfg.id} #custombutton div input:nth-of-type(1) { width: 2em; } #${cfg.id} #custombutton div input:nth-of-type(2) { width: 6em; } #${cfg.id} #custombutton div input:nth-of-type(3) { width: 10em; } #${cfg.id} #custombutton div input:nth-of-type(4) { width: 10em; } </style>`); // the available standard buttons, display & insert stuff let config_std = new Map([ ["bold", { icon: "", text: "Bold", ins_pre: "<b>", ins_app: "</b>" }], ["italic", { icon: "", text: "Italic", ins_pre: "<em>", ins_app: "</em>" }], ["underline", { icon: "", text: "Underline", ins_pre: "<u>", ins_app: "</u>" }], ["strike", { icon: "", text: "Strikethrough", ins_pre: "<s>", ins_app: "</s>" }], ["link", { icon: "", text: "Link", ins_pre: "<a href=\"\">", ins_app: "</a>" }], ["image", { icon: "", text: "Image", ins_pre: "<img src=\"", ins_app: "\" />" }], ["quote", { icon: "", text: "Quote", ins_pre: "<blockquote>", ins_app: "</blockquote>" }], ["paragraph", { icon: "", text: "Paragraph", ins_pre: "<p>", ins_app: "</p>" }], ["listnum", { icon: "", text: "Numbered List", ins_pre: "<ol><li>", ins_app: "</li></ol>" }], ["listbull", { icon: "", text: "Bullet List", ins_pre: "<ul><li>", ins_app: "</li></ul>" }], ["listitem", { icon: "", text: "List Item", ins_pre: "<li>", ins_app: "</li>" }], ]); // build GUI for chosen enable/disable of standard buttons let standardbuttons = ''; config_std.forEach((val, key) => { 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"' : ""}>`; }); // reformat the stored custom buttons to match the standard let config_custom = new Map(); custommap.forEach((val, key) => { val = JSON.parse(val); // turn the string into an array of 4x2 each let newval = {}; // turn the array into an object val.forEach((v) => { newval[v[0]] = v[1]; }); config_custom.set(key, newval); }); // build GUI for stored custom buttons let custombuttons = ''; config_custom.forEach((val) => { custombuttons += `<div><button class="remove">-</button><input type="text" name="icon" value="${val.icon}"><input type="text" name="text" value="${val.text}"> <input type="text" name="ins_pre" value="${val.ins_pre}"><input type="text" name="ins_app" value="${val.ins_app}"></div>`; }); // template for a blank row to add a custom button (is cloned before inserting into DOM) let newcustombutton = `<div><button class="remove">-</button><input type="text" name="icon" value="Icon"><input type="text" name="text" value="Title"> <input type="text" name="ins_pre" value="Insert Before"><input type="text" name="ins_app" value="Insert After"></div>`; $(cfg).html(`<form> <fieldset id='stdbutton'> <legend>Standard text formatting</legend> <p>Select the buttons you'd like to see as options on the button bar.</p> ${standardbuttons} </fieldset> <fieldset id='custombutton'> <legend>Custom HTML or text</legend> <p>Define custom buttons, which will insert HTML and/or text.</p> <ul><li>In the first field, choose <a href="https://fontawesome.com/v4/icons/">the Icon</a> you want on the button.<br /> Copy its 4-letter Unicode (for example "f004" for the heart) into this field.</li> <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> <li>Put the text you want inserted around the cursor position into the Insert Before and Insert After fields.</li></ul> ${custombuttons} <div><button class="add">+</button></div> </fieldset> <p>Any changes only apply after reloading the page.</p> </form>`); // attach it to the DOM so that selections work (but only if #main exists, else it might be a Retry Later error page) if ($("#main").length == 1) $("body").append(cfg); // turn checkboxes and radiobuttons into pretty buttons $( "#cmtFmtDialog input[type='checkbox'], #cmtFmtDialog input[type='radio']" ).checkboxradio({ icon: false }); // optimizing the size of the GUI in case it's a mobile device let dialogwidth = parseInt($("body").css("width")); // parseInt ignores letters (px) dialogwidth = dialogwidth > 550 ? 550 : dialogwidth * 0.9; // initialize the dialog (but don't open it) $( "#cmtFmtDialog" ).dialog({ appendTo: "#main", modal: true, title: 'Comment Formatting Buttons', draggable: true, resizable: false, autoOpen: false, width: dialogwidth, position: {my:"center", at: "center top"}, buttons: { Reset: deleteConfig, Save: storeConfig, Cancel: function() { $( "#cmtFmtDialog" ).dialog( "close" ); } } }); // event triggers if form is submitted with the <enter> key $( "#cmtFmtDialog form" ).on("submit", (e) => { e.preventDefault(); storeConfig(); }); // putting event triggers on buttons that will delete custom rows function evRemoveRow(el) { $(el).on("click", (e) => { e.cancelBubble = true; e.preventDefault(); $(e.target).parent().remove(); // delete whole div }); } // run it immediately on the stored custom buttons evRemoveRow($( "#cmtFmtDialog button.remove" )); // putting event trigger on button that will add blank custom rows $( "#cmtFmtDialog button.add" ).on("click", (e) => { e.cancelBubble = true; e.preventDefault(); // add a new blank row and attach the remove event again $(e.target).parent().before( $(newcustombutton).clone() ); evRemoveRow($( "#cmtFmtDialog button.remove:last-of-type" )); }); function deleteConfig() { // deselects all buttons, empties all fields in the form $('#cmtFmtDialog form').trigger("reset"); $('#cmtFmtDialog button.remove').trigger("click"); // deletes the localStorage localStorage.removeItem('cmtfmtstandard'); localStorage.removeItem('cmtfmtcustom'); $( "#cmtFmtDialog" ).dialog( "close" ); } function storeConfig() { // build a Map() for enabled standard buttons => button -> true/false let storestd = new Map(); $( "#cmtFmtDialog #stdbutton [name]" ).each(function() { storestd.set( $(this).prop('name'), String($(this).prop('checked')) ); }); localStorage.setItem('cmtfmtstandard', JSON.stringify(Array.from(storestd.entries()))); // build a Map() for the custom buttons => custom# -> { icon: X, text: X, ins_pre: X, ins_app: X } let storecustom = new Map(); $( "#cmtFmtDialog #custombutton div:has(input)" ).each((i, div) => { let parts = new Map(); $(div).find('[name]').each(function() { parts.set( $(this).prop('name'), $(this).prop('value') ); }); storecustom.set('custom'+i, JSON.stringify(Array.from(parts.entries()))); }); localStorage.setItem('cmtfmtcustom', JSON.stringify(Array.from(storecustom.entries()))); $( "#cmtFmtDialog" ).dialog( "close" ); } /* CREATING THE LINK TO OPEN THE CONFIGURATION DIALOG */ // if no other script has created it yet, write out a "Userscripts" option to the main navigation if ($('#scriptconfig').length == 0) { $('#header ul.primary.navigation li.dropdown').last() .after(`<li class="dropdown" id="scriptconfig"> <a class="dropdown-toggle" href="/" data-toggle="dropdown" data-target="#">Userscripts</a> <ul class="menu dropdown-menu"></ul></li>`); } // then add this script's config option to navigation dropdown $('#scriptconfig .dropdown-menu').append(`<li><a href="javascript:void(0);" id="opencfg_cmtfmt">Comment Formatting Buttons</a></li>`); // on click, open the configuration dialog $("#opencfg_cmtfmt").on("click", function(e) { $( "#cmtFmtDialog" ).dialog('open'); }); /********************************************************* COMMENT BAR AND PREVIEW FUNCTIONALITY *********************************************************/ // merge the enabled standard and custom buttons into one list let config = new Map(); config_std.forEach((val, key) => { if (standardmap.get(key)==="true" || standardmap.size == 0) config.set(key, val); }); config_custom.forEach((val, key) => { if (val.icon !== "") val.icon = `&#x${val.icon};`; // add what Font Awesome needs to display properly config.set(key, val); }); $("head").append(`<style type="text/css"> ul.comment-format { font-family: FontAwesome, sans-serif; float: left; } ul.comment-format a { cursor: default; } ul.comment-format .fontawe { font-family: FontAwesome, sans-serif; } div.comment-preview.userstuff { border: 1px inset #f0f0f0; min-height: 1em; padding: 0.2em 1em; line-height: 1.5; } </style>`); // collate the button bar let buttonBar = document.createElement('ul'); $(buttonBar).addClass('actions comment-format'); for (let c of config) { let li = document.createElement('li'); li.title = c[1].text; li.innerHTML = `<a class="${c[0]}">${ (c[1].icon === "") ? c[1].text : c[1].icon}</a>`; if (c[1].icon !== "") $(li).addClass("fontawe"); $(buttonBar).append(li); } $(buttonBar).find('a').on('click', function(e) { e.cancelBubble = true; e.preventDefault(); insert_format(e.target); }); // preview box template (will be cloned when inserting into DOM) let preview = `<div class='comment-preview userstuff' title='Comment Preview (approximate)'></div>`; // click event function called with the button <a> that was clicked (so we know which textarea to insert it to) function insert_format(elm) { let area = $(elm).parent().parent().next('textarea')[0]; // the textarea element we're dealing with let text = $(area).val(); // the original content of the comment box let cursor_start = area.selectionStart, cursor_end = area.selectionEnd; // any highlighted text let fmt = config.get(elm.className); // grab the formatting HTML corresponding to the clicked button // set the comment box text with the new content, and focus back on it $(area).val( text.slice(0, cursor_start) + // text from before cursor position or highlight fmt.ins_pre + text.slice(cursor_start, cursor_end) + fmt.ins_app + // wrap any highlighted text in the formatting HTML text.slice(cursor_end) // text from after cursor position or highlight ).focus(); // set the cursor position to the same value so we don't highlight anymore let cursor_new = // if we only inserted format HTML, set it between the halves so you can enter the text to format (cursor_start == cursor_end) ? cursor_start + fmt.ins_pre.length : // if we highlighted, and this is a link (so the link text is already done), set the cursor into the href="" (elm.className == "link") ? cursor_start + fmt.ins_pre.length - 2 : // otherwise always set it at the end of the inserted text i.e. the same distance from the end as originally $(area).val().length - (text.length - cursor_end); area.selectionStart = area.selectionEnd = cursor_new; // manually trigger the value-has-changed event so the preview updates (not calling update_preview directly as it would fail on Sticky Comment Box) $(area).trigger('input'); } // function called when anything changes (input event trigger) in the textarea function update_preview(elm) { let prevbox = $(elm).siblings('div.comment-preview')[0]; prevbox.innerHTML = parse_preview($(elm).val()); } // adding the button bar & preview box for any visible comment area (clone with events!) $('textarea[id^="comment_content_for"]') .before($(buttonBar).clone(true, true)) .after($(preview).clone()) .on('input', function(e) { update_preview(e.target); }) .each(function() { update_preview(this); }); // update the preview for reloaded pages with cached comment text // Support for Sticky Comment Box! // if this script executes first, we may have to wait for the Sticky Comment Box to appear in the DOM if ($('#float_cmt_dlg').length == 0) { const observer = new MutationObserver(function(mutList, obs) { for (const mut of mutList) { for (const node of mut.addedNodes) { // check if the added node is our comment box if (node.id == 'float_cmt_dlg') { obs.disconnect(); // stop listening immediately, we have what we needed // add the buttonbar to the Sticky Comment Box (it doesn't get a preview field to save space) $('#float_cmt_userinput textarea').before($(buttonBar).clone(true, true).css('font-size', '80%')); } }} }); // listening to as few changes as possible: only direct children of <body> observer.observe($('body').get(0), { attributes: false, childList: true, subtree: false }); // failsafe: stop listening after 5 seconds (in case the other script isn't running) // this will always execute even if the box was already found and the observer disconnected previously let timeout = setTimeout(() => { observer.disconnect(); }, 5 * 1000); } // when the Sticky Comment Box script executed first and the textarea is already there, we immediately add the button bar else $('#float_cmt_userinput textarea').before($(buttonBar).clone(true, true).css('font-size', '80%')); // adding the bar for any loaded comment areas: inbox replies, work/tag replies, editing existing comments const waitforreply = new MutationObserver(function(mutList, obs) { for (const mut of mutList) { for (const node of mut.addedNodes) { // check if the added node is our comment box if (node.nodeType == 1 && node.id.startsWith('comment_form_for')) { $(node).find('textarea') .before($(buttonBar).clone(true, true)) .after($(preview).clone()) .on('input', function(e) { update_preview(e.target); }) .each(function() { update_preview(this); }); // update the preview with the existing comment text } }} }); // listening to the places where Ao3 adds the HTML in for the reply box waitforreply.observe($('#feedback, #reply-to-comment').get(0), { attributes: false, childList: true, subtree: true }); function parse_preview(content) { // if the comment box is still empty, show a simple placeholder if (content == "") return "<p><i>preview</i></p>"; // if there is comment text, turn double linebreaks into paragraphs and single linebreaks into <br> // linebreak compatibility const lbr = (content.indexOf("\r\n") > -1) ? "\r\n" : (content.indexOf("\r") > -1) ? "\r" : "\n"; // remove obvious issues: whitespaces between <li>'s, a <br> plus linebreak (while editing) content = content.replace(/<\/li>\W+<li>/ig, '</li><li>'); content = content.replace(/<br \/>(\r\n|\r|\n)/ig, '<br />'); content = content.split(`${lbr}${lbr}`); // split content at each two linebreaks in a row const regexLine = new RegExp(`${lbr}`, "g"); content.forEach((v, i) => { v = v.replace(regexLine, "<br />"); // a single linebreak is replaced by a <br> content[i] = "<p>"+v.trim()+"</p>"; // two linebreaks are wrapped in a <p> }); return content.join(lbr); } })(jQuery); // helper function to determine whether a color (the background in use) is light or dark // https://awik.io/determine-color-bright-dark-using-javascript/ function lightOrDark(color) { var r, g, b, hsp; if (color.match(/^rgb/)) { color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/); r = color[1]; g = color[2]; b = color[3]; } else { color = +("0x" + color.slice(1).replace(color.length < 5 && /./g, '$&$&')); r = color >> 16; g = color >> 8 & 255; b = color & 255; } hsp = Math.sqrt( 0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b) ); if (hsp>127.5) { return 'light'; } else { return 'dark'; } }