Greasy Fork

GitHub Auto-Indent Comments

A userscript that allows you to indent & outdent blocks and auto indent for new line in the comment editor

//
// ==UserScript==
// @name        GitHub Auto-Indent Comments
// @version     0.0.2
// @description A userscript that allows you to indent & outdent blocks and auto indent for new line in the comment editor
// @license     MIT
// @author      ly525
// @namespace   https://github.com/ly525
// @include     https://github.com/*
// @include     https://gist.github.com/*
// @run-at      document-idle
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_registerMenuCommand
// @connect     github.com
// @require     https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=666427
// @icon        https://github.githubassets.com/pinned-octocat.svg
// ==/UserScript==

/*
 * ly525 says:
 * HEAVILY MODIFIED 3/8/2020 from https://greasyfork.org/zh-CN/scripts/28176-github-indent-comments
 * Thanks a lot!
 */

/* HEAVILY MODIFIED 3/17/2017 from https://github.com/timdown/rangyinputs
 * - The code was unwrapped
 * - jQuery elements removed, updated to ES2015
 * - Unneeded code removed
 * - Added global variable "rangyInput"
 */


/**
* @license Rangy Inputs, a jQuery plug-in for selection and caret manipulation
* within textareas and text inputs.
*
* https://github.com/timdown/rangyinputs
*
* For range and selection features for contenteditable, see Rangy.
* http://code.google.com/p/rangy/
*
* xxxx Depends on jQuery 1.0 or later. xxxxx
*
* Copyright 2014, Tim Down
* Licensed under the MIT license.
* Version: 1.2.0
* Build date: 30 November 2014
*/
/* jshint esnext:true */
(() => {

  window.rangyInput = {};

  const UNDEF = "undefined";
  let getSelection, setSelection;

  // Trio of isHost* functions taken from Peter Michaux's article:
  // State of the art browser scripting (https://goo.gl/w6HPyE)
  function isHostMethod(object, property) {
    var t = typeof object[property];
    return t === "function" ||
      (!!(t == "object" && object[property])) ||
      t == "unknown";
  }
  function isHostProperty(object, property) {
    return typeof(object[property]) != UNDEF;
  }
  function isHostObject(object, property) {
    return !!(typeof(object[property]) == "object" && object[property]);
  }
  function fail(reason) {
    if (window.console && window.console.log) {
      window.console.log(
        `RangyInputs not supported in your browser. Reason: ${reason}`
      );
    }
  }

  function adjustOffsets(el, start, end) {
    if (start < 0) {
      start += el.value.length;
    }
    if (typeof end == UNDEF) {
      end = start;
    }
    if (end < 0) {
      end += el.value.length;
    }
    return { start: start, end: end };
  }

  function makeSelection(el, start, end) {
    return {
      start: start,
      end: end,
      length: end - start,
      text: el.value.slice(start, end)
    };
  }

  function getBody() {
    return isHostObject(document, "body") ?
      document.body :
      document.querySelector("body");
  }

  window.rangyInput.init = () => {
    const testTextArea = document.createElement("textarea");
    getBody().appendChild(testTextArea);

    if (
      isHostProperty(testTextArea, "selectionStart") &&
      isHostProperty(testTextArea, "selectionEnd")
    ) {

      getSelection = el => {
        return makeSelection(el, el.selectionStart, el.selectionEnd);
      };

      setSelection = (el, startOffset, endOffset) => {
        var offsets = adjustOffsets(el, startOffset, endOffset);
        el.selectionStart = offsets.start;
        el.selectionEnd = offsets.end;
      };

    } else if (
      isHostMethod(testTextArea, "createTextRange") &&
      isHostObject(document, "selection") &&
      isHostMethod(document.selection, "createRange")
    ) {

      getSelection = el => {
        let normalizedValue, textInputRange, len, endRange,
          start = 0,
          end = 0;
        const range = document.selection.createRange();

        if (range && range.parentElement() == el) {
          len = el.value.length;
          normalizedValue = el.value.replace(/\r\n/g, "\n");
          textInputRange = el.createTextRange();
          textInputRange.moveToBookmark(range.getBookmark());
          endRange = el.createTextRange();
          endRange.collapse(false);
          if (textInputRange.compareEndPoints("StartToEnd", endRange) > -1) {
            start = end = len;
          } else {
            start = -textInputRange.moveStart("character", -len);
            start += normalizedValue.slice(0, start).split("\n").length - 1;
            if (textInputRange.compareEndPoints("EndToEnd", endRange) > -1) {
              end = len;
            } else {
              end = -textInputRange.moveEnd("character", -len);
              end += normalizedValue.slice(0, end).split("\n").length - 1;
            }
          }
        }
        return makeSelection(el, start, end);
      };

      // Moving across a line break only counts as moving one character in a
      // TextRange, whereas a line break in the textarea value is two
      // characters. This function corrects for that by converting a text offset
      // into a range character offset by subtracting one character for every
      // line break in the textarea prior to the offset
      const offsetToRangeCharacterMove = function(el, offset) {
        return offset - (el.value.slice(0, offset).split("\r\n").length - 1);
      };

      setSelection = (el, startOffset, endOffset) => {
        const offsets = adjustOffsets(el, startOffset, endOffset),
          range = el.createTextRange(),
          startCharMove = offsetToRangeCharacterMove(el, offsets.start);
        range.collapse(true);
        if (offsets.start == offsets.end) {
          range.move("character", startCharMove);
        } else {
          range.moveEnd(
            "character",
            offsetToRangeCharacterMove(el, offsets.end)
          );
          range.moveStart("character", startCharMove);
        }
        range.select();
      };

    } else {
      getBody().removeChild(testTextArea);
      fail("No means of finding text input caret position");
      return;
    }

    // Clean up
    getBody().removeChild(testTextArea);

    function getValueAfterPaste(el, text) {
      const val = el.value,
        sel = getSelection(el),
        selStart = sel.start;
      return {
        value: val.slice(0, selStart) + text + val.slice(sel.end),
        index: selStart,
        replaced: sel.text
      };
    }

    function pasteTextWithCommand(el, text) {
      el.focus();
      const sel = getSelection(el);

      // Hack to work around incorrect delete command when deleting the last
      // word on a line
      setSelection(el, sel.start, sel.end);
      if (text === "") {
        document.execCommand("delete", false, null);
      } else {
        document.execCommand("insertText", false, text);
      }

      return {
        replaced: sel.text,
        index: sel.start
      };
    }

    function pasteTextWithValueChange(el, text) {
      el.focus();
      const valueAfterPaste = getValueAfterPaste(el, text);
      el.value = valueAfterPaste.value;
      return valueAfterPaste;
    }

    let pasteText = (el, text) => {
      const valueAfterPaste = getValueAfterPaste(el, text);
      try {
        const pasteInfo = pasteTextWithCommand(el, text);
        if (el.value == valueAfterPaste.value) {
          pasteText = pasteTextWithCommand;
          return pasteInfo;
        }
      } catch (ex) {
        // Do nothing and fall back to changing the value manually
      }
      pasteText = pasteTextWithValueChange;
      el.value = valueAfterPaste.value;
      return valueAfterPaste;
    };

    function updateSelectionAfterInsert(el, startIndex, text, selBehaviour) {
      let endIndex = startIndex + text.length;
      selBehaviour = (typeof selBehaviour == "string") ?
        selBehaviour.toLowerCase() :
        "";

      if (
        (selBehaviour == "collapsetoend" || selBehaviour == "select") &&
        /[\r\n]/.test(text)
      ) {
        // Find the length of the actual text inserted, which could vary
        // depending on how the browser deals with line breaks
        const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
        endIndex = startIndex + normalizedText.length;
        const firstLineBreakIndex = startIndex + normalizedText.indexOf("\n");

        if (
          el.value.slice(firstLineBreakIndex, firstLineBreakIndex + 2) == "\r\n"
        ) {
          // Browser uses \r\n, so we need to account for extra \r characters
          endIndex += normalizedText.match(/\n/g).length;
        }
      }

      switch (selBehaviour) {
        case "collapsetostart":
          setSelection(el, startIndex, startIndex);
          break;
        case "collapsetoend":
          setSelection(el, endIndex, endIndex);
          break;
        case "select":
          setSelection(el, startIndex, endIndex);
          break;
      }
    }

    window.rangyInput.surroundSelectedText = (el, before, after) => {
      if (typeof after == UNDEF) {
        after = before;
      }
      const sel = getSelection(el),
        pasteInfo = pasteText(el, before + sel.text + after);
      updateSelectionAfterInsert(
        el,
        pasteInfo.index + before.length,
        sel.text,
        "select"
      );
    };

    window.rangyInput.indentSelectedText = (el, callback) => {
      const sel = getSelection(el),
        result = callback(sel.text),
        pasteInfo = pasteText(el, result);
      //「修改1」主要修改了这里,将updateSelectionAfterInsert的值由「select」改为「collapsetoend」
      updateSelectionAfterInsert(el, pasteInfo.index, result, "collapsetoend");
    };
  };
})();

(() => {
  "use strict";

  let spaceSize = GM_getValue("space-size", 2);

  const icons = {
    indent: `
      <svg class="octicon" xmlns="http://www.w3.org/2000/svg" width="12" height="16" viewBox="0 0 12 16">
        <path d="M12 13c0 .6 0 1-.9 1H.9c-.9 0-.9-.4-.9-1s0-1 .9-1h10.2c.88 0 .88.4.88 1zM.92 4h10.2C12 4 12 3.6 12 3s0-1-.9-1H.92c-.9 0-.9.4-.9 1s0 1 .9 1zM11.5 7h-5C6 7 6 7.4 6 8s0 1 .5 1h5c.5 0 .5-.4.5-1s0-1-.5-1zm-7 1L0 5v6z"/>
      </svg>`,
    outdent: `
      <svg class="octicon" xmlns="http://www.w3.org/2000/svg" width="12" height="16" viewBox="0 0 12 16">
        <path d="M12 13c0 .6 0 1-.9 1H.9c-.9 0-.9-.4-.9-1s0-1 .9-1h10.2c.88 0 .88.4.88 1zM.92 4h10.2C12 4 12 3.6 12 3s0-1-.9-1H.92c-.9 0-.9.4-.9 1s0 1 .9 1zm10.7 3H6.4c-.46 0-.4.4-.4 1s-.06 1 .4 1h5.2c.47 0 .4-.4.4-1s.07-1-.4-1zM0 8l4.5-3v6z"/>
      </svg>`
  };

  GM_addStyle(".ghio-in-outdent * { pointer-events:none; }");

  // Add indent & outdent buttons
  function addButtons() {
    createButton("Outdent");
    createButton("Indent");
  }

  function createButton(name) {
    const toolbars = $$(".toolbar-commenting"),
      nam = name.toLowerCase(),
      button = document.createElement("button");
    let el,
      indx = toolbars.length;
    if (indx) {
      button.type = "button";
      button.className = `ghio-${nam.toLowerCase()} ghio-in-outdent toolbar-item tooltipped tooltipped-n`;
      button.setAttribute("aria-label", `${name} Selected Text`);
      button.setAttribute("tabindex", "-1");
      button.innerHTML = icons[nam.toLowerCase()];
      while (indx--) {
        el = toolbars[indx];
        if (!$(`.ghio-${nam.toLowerCase()}`, el)) {
          el.insertBefore(button.cloneNode(true), el.childNodes[0]);
        }
      }
    }
  }

  function indent(text) {
    let result = [],
      block = new Array(parseInt(spaceSize, 10) + 1).join(" ");
    (text || "").split(/\r*\n/).forEach(line => {
      result.push(block + line);
    });
    return result.join("\n");
  }

  function outdent(text) {
    let regex = new RegExp(`^(\x20{1,${spaceSize}}|\xA0{1,${spaceSize}}|\x09)`),
      result = [];
    (text || "").split(/\r*\n/).forEach(line => {
      result.push(line.replace(regex, ""));
    });
    return result.join("\n");
  }

  function addBindings() {
    window.rangyInput.init();
    saveTabSize();
    $("body").addEventListener("click", event => {
			let textarea,
				target = event.target;
			if (target && target.classList.contains("ghio-in-outdent")) {
				textarea = closest(".previewable-comment-form", target);
				textarea = $(".comment-form-textarea", textarea);
				textarea.focus();
				setTimeout(() => {
					window.rangyInput.indentSelectedText(
						textarea,
						target.classList.contains("ghio-indent") ? indent : outdent
					);
				}, 100);
				return false;
			}
		});
    // Add Tab & Shift + Tab
    $("body").addEventListener("keydown", event => {
      if (event.key === "Tab") {
        let target = event.target;
        if (target && target.classList.contains("comment-form-textarea")) {
          event.preventDefault();
          target.focus();
          setTimeout(() => {
            window.rangyInput.indentSelectedText(
              target,
              // shift + tab = outdent
              event.getModifierState("Shift") ? outdent : indent
            );
          }, 100);
        }
      }
      // 「修改2」增加 New Line 自动对齐行为
      // https://stackoverflow.com/questions/5743916/how-to-add-autoindent-to-html-textarea
      else if (event.key === 'Enter') {
          let target = event.target;
          if (target && target.classList.contains("comment-form-textarea")) {
            // event.preventDefault();
            setTimeout((that) => {
                          var start = that.selectionStart;
                          var v = that.value;
                          var thisLine = "";
                          var indentation = 0;
                          for (let i = start - 2; i >= 0 && v[i] != "\n"; i--) {
                              thisLine = v[i] + thisLine;
                          }
                          for (let i = 0; i < thisLine.length && thisLine[i] == " "; i++) {

                              indentation++;
                          }
                          that.value = v.slice(0, start) + " ".repeat(indentation) + v.slice(start);
                          that.selectionStart = start + indentation;
                          that.selectionEnd = start + indentation;
            }, 0.01, target);
          }
      }
    });
  }

  function saveTabSize() {
    let $el = $(".gh-indent-size");
    if (!$el) {
      $el = document.createElement("style");
      $el.setAttribute("rel", "stylesheet");
      $el.className = "gh-indent-size";
      document.querySelector("head").appendChild($el);
    }
    $el.innerHTML = `.comment-form-textarea { tab-size:${spaceSize}; }`;
  }

  function $(selector, el) {
    return (el || document).querySelector(selector);
  }

  function $$(selector, el) {
    return Array.from((el || document).querySelectorAll(selector));
  }

  function closest(selector, el) {
    while (el && el.nodeType === 1) {
      if (el.matches(selector)) {
        return el;
      }
      el = el.parentNode;
    }
    return null;
  }

  // Add GM options
  GM_registerMenuCommand(
    "Indent or outdent size",
    () => {
      const spaces = GM_getValue("indentOutdentSize", spaceSize);
      let val = prompt("Enter number of spaces to indent or outdent:", spaces);
      if (val !== null && typeof val === "string") {
        spaceSize = val;
        GM_setValue("space-size", val);
        saveTabSize();
      }
    }
  );

  document.addEventListener("ghmo:container", addButtons);
  addBindings();
  addButtons();
})();