您需要先安装一个扩展,例如 篡改猴、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 1.0
- // @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/comments/*
- // @match *://*.archiveofourown.org/comments?*
- // @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>" }],
- ]);
- 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"' : ""}>`;
- });
- 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>`;
- // turn custom buttons into Map items
- /*
- ["custom1", { icon: "", text: "Translation", ins_pre: "Translation: ", ins_app: "" }]
- */
- 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);
- });
- let custombuttons = '';
- config_custom.forEach((val, key) => {
- 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>`;
- });
- $(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 => true/false
- let storestd = new Map();
- $( "#cmtFmtDialog #stdbutton [name]" ).each((i, fmt) => {
- storestd.set($(fmt).prop('name'), String($(fmt).prop('checked')));
- });
- localStorage.setItem('cmtfmtstandard', JSON.stringify(Array.from(storestd.entries())));
- let storecustom = new Map();
- $( "#cmtFmtDialog #custombutton div:has(input)" ).each((i, div) => {
- let parts = new Map();
- $(div).find('[name]').each((j, fmt) => { parts.set($(fmt).prop('name'), $(fmt).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
- 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;
- // update the preview too, since the events don't fire through javascript
- update_preview(area);
- }
- // input event function when anything changes 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); })
- // update the preview too, in case we're reloading the page with cached comment text
- .each(function() { update_preview(this); });
- // adding the bar for any loaded comment areas
- // global AJAX listener but we're only interested in the calls that add the comment reply box
- XMLHttpRequest.prototype.getResponseHeader = function() { // jQuery ajaxSuccess method doesn't catch the reply pages
- if (!(this.readyState == 4 && this.status == 200)) return true;
- var xhrurl = this.responseURL;
- var params = (new URL(xhrurl)).searchParams;
- // When replying to comments (on work or tag page)
- if (xhrurl.indexOf("comments/add_comment_reply?") !== -1) {
- $('textarea#comment_content_for_'+params.get("id"))
- .before($(buttonBar).clone(true, true))
- .after($(preview).clone())
- .on('input', function(e) { update_preview(e.target); });
- }
- // When replying to inbox comments (floating box)
- else if (xhrurl.indexOf("inbox/reply?") !== -1) {
- $('textarea#comment_content_for_'+params.get("comment_id"))
- .before($(buttonBar).clone(true, true))
- .after($(preview).clone())
- .on('input', function(e) { update_preview(e.target); });
- }
- // When editing a comment
- else if (xhrurl.indexOf("/comments/") !== -1 && xhrurl.indexOf("/edit") !== -1) {
- let commentid = xhrurl.match(/\d+/);
- $('li#comment_'+commentid[0]+' textarea[id^=comment_content_for_]')
- .before($(buttonBar).clone(true, true))
- .after($(preview).clone())
- .on('input', function(e) { update_preview(e.target); });
- update_preview($('li#comment_'+commentid[0]+' textarea[id^=comment_content_for_]'));
- }
- };
- 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";
- const splitPara = `${lbr}${lbr}`;
- const regexLine = new RegExp(`${lbr}`, "g");
- // 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(splitPara); // split content at each two linebreaks in a row
- 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) {
- // Variables for red, green, blue values
- var r, g, b, hsp;
- // Check the format of the color, HEX or RGB?
- if (color.match(/^rgb/)) {
- // If RGB --> store the red, green, blue values in separate variables
- color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/);
- r = color[1];
- g = color[2];
- b = color[3];
- }
- else {
- // If hex --> Convert it to RGB: http://gist.github.com/983661
- color = +("0x" + color.slice(1).replace(color.length < 5 && /./g, '$&$&'));
- r = color >> 16;
- g = color >> 8 & 255;
- b = color & 255;
- }
- // HSP (Highly Sensitive Poo) equation from http://alienryderflex.com/hsp.html
- hsp = Math.sqrt( 0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b) );
- // Using the HSP value, determine whether the color is light or dark
- if (hsp>127.5) { return 'light'; }
- else { return 'dark'; }
- }