Textarea Plus

Have a better textarea! A userscript which can improve plain textarea for code editing.

当前为 2018-03-15 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Textarea Plus
  3. // @version 3.0.0
  4. // @description Have a better textarea! A userscript which can improve plain textarea for code editing.
  5. // @homepageURL https://github.com/eight04/textarea-plus
  6. // @supportURL https://github.com/eight04/textarea-plus/issues
  7. // @license MIT
  8. // @author eight04 <eight04@gmail.com>
  9. // @namespace eight04.blogspot.com
  10. // @include *
  11. // @grant GM_getValue
  12. // @grant GM_setValue
  13. // @grant GM_registerMenuCommand
  14. // @grant GM_addStyle
  15. // @compatible firefox Tampermonkey latest
  16. // @compatible chrome Tampermonkey latest
  17. // @require https://greasyfork.org/scripts/7212-gm-config-eight-s-version/code/GM_config%20(eight's%20version).js?version=156587
  18. // ==/UserScript==
  19.  
  20. var textareaPlus = (function () {
  21. 'use strict';
  22.  
  23. /* eslint-env node */
  24.  
  25. function isSameLine(editor) {
  26. return !editor.getSelection().includes("\n");
  27. }
  28.  
  29. function getIndentInfo(text, {indentSize}) {
  30. var i, count = 0;
  31. for (i = 0; i < text.length; i++) {
  32. var c = text[i];
  33. if (c == " ") {
  34. count++;
  35. } else if (c == "\t") {
  36. count += indentSize;
  37. } else {
  38. break;
  39. }
  40. }
  41. return {
  42. count: Math.floor(count / indentSize),
  43. extraSpaces: count % indentSize,
  44. length: i
  45. };
  46. }
  47.  
  48. function getIndentChar({indentStyle, indentSize}) {
  49. if (indentStyle === "TAB") {
  50. return "\t";
  51. }
  52. return " ".repeat(indentSize);
  53. }
  54.  
  55. function runIndent({editor, options}) {
  56. if (!isSameLine(editor)) {
  57. runMultiIndent(editor, options);
  58. return;
  59. }
  60. var range = editor.getSelectionLineRange(),
  61. line = editor.getSelectionLine(),
  62. indent = getIndentInfo(line, options),
  63. pos = editor.getSelectionRange().start;
  64. if (pos > range.start + indent.length) {
  65. editor.setRangeText(
  66. getIndentChar(options),
  67. pos,
  68. pos,
  69. "end"
  70. );
  71. } else {
  72. editor.setRangeText(
  73. getIndentChar(options).repeat(indent.count + 1),
  74. range.start,
  75. range.start + indent.length,
  76. "end"
  77. );
  78. }
  79. }
  80.  
  81. function runUnindent({editor, options}) {
  82. if (!isSameLine(editor)) {
  83. runMultiIndent(editor, options, -1);
  84. return;
  85. }
  86. var range = editor.getSelectionLineRange(),
  87. line = editor.getSelectionLine(),
  88. indent = getIndentInfo(line, options),
  89. pos = editor.getCaretPos(true),
  90. indentChar = getIndentChar(options);
  91. const indentCount = indent.count + (indent.extraSpaces ? 1 : 0);
  92. if (pos <= range.start + indent.length && indentCount) {
  93. editor.setRangeText(
  94. indentChar.repeat(indentCount - 1),
  95. range.start,
  96. range.start + indent.length,
  97. "end"
  98. );
  99. } else if (line.slice(0, pos - range.start).endsWith(indentChar)) {
  100. editor.setRangeText(
  101. "", pos - indentChar.length, pos, "end"
  102. );
  103. }
  104. }
  105.  
  106. function runMultiIndent(editor, options, diff = 1) {
  107. var range = editor.getSelectionRange(),
  108. lines = editor.getSelectionLine(),
  109. lineRange = editor.getSelectionLineRange();
  110. if (lines[range.end - lineRange.start - 1] == "\n") {
  111. lineRange.end = range.end - 1;
  112. lines = lines.slice(0, range.end - lineRange.start - 1);
  113. }
  114. lines = lines.split("\n").map(line => {
  115. if (!line) return line;
  116. var indent = getIndentInfo(line, options),
  117. count = indent.count + diff;
  118. if (count < 0) {
  119. count = 0;
  120. // remove extra space when there is no indent
  121. indent.extraSpaces = 0;
  122. }
  123. return getIndentChar(options).repeat(count) +
  124. " ".repeat(indent.extraSpaces) +
  125. line.slice(indent.length);
  126. }).join("\n");
  127. editor.setRangeText(lines, lineRange.start, lineRange.end);
  128. editor.setSelectionRange(lineRange.start, lineRange.start + lines.length + 1);
  129. }
  130.  
  131. function runSmartHome({editor, options, event}) {
  132. const collapse = !event.shiftKey;
  133. var line = editor.getCurrentLine(),
  134. range = editor.getCurrentLineRange(),
  135. pos = editor.getCaretPos(collapse) - range.start,
  136. indent = getIndentInfo(line, options);
  137. if (pos == indent.length) {
  138. editor.setCaretPos(range.start, collapse);
  139. } else {
  140. editor.setCaretPos(range.start + indent.length, collapse);
  141. }
  142. }
  143.  
  144. const BRACES = {
  145. __proto__: null,
  146. "[": "]",
  147. "{": "}",
  148. "(": ")",
  149. };
  150.  
  151. function runNewLine({editor, options}) {
  152. var content = editor.getContent(),
  153. range = editor.getSelectionRange(),
  154. line = editor.getSelectionLine(),
  155. lineRange = editor.getLineRange(range.start, range.start),
  156. indent = getIndentInfo(line, options),
  157. out = "\n", pos,
  158. left = content[range.start - 1],
  159. right = content[range.end];
  160.  
  161. if (/[[{(]/.test(left)) {
  162. out += getIndentChar(options).repeat(indent.count + 1);
  163. } else {
  164. out += line.slice(0, Math.min(indent.length, range.start - lineRange.start));
  165. }
  166. pos = range.start + out.length;
  167. if (BRACES[left] && right == BRACES[left]) {
  168. out += "\n" + line.slice(0, indent.length);
  169. }
  170. editor.setRangeText(out);
  171. editor.setSelectionRange(pos, pos);
  172. }
  173.  
  174. function runCompleteBraces({editor, options, event}) {
  175. const left = event.key;
  176. const right = options.completeBraces[left];
  177. var text = editor.getSelection(),
  178. range = editor.getSelectionRange();
  179. editor.setRangeText(left + text + right, range.start, range.end);
  180. editor.setSelectionRange(range.start + 1, range.start + 1 + text.length);
  181. }
  182.  
  183. const COMMANDS = [
  184. {
  185. // indent
  186. test: e => e.key === "Tab" && !e.shiftKey,
  187. run: runIndent
  188. },
  189. {
  190. // unindent
  191. test: e => e.key === "Tab" && e.shiftKey,
  192. run: runUnindent
  193. },
  194. {
  195. // smart home
  196. test: e => e.key === "Home",
  197. run: runSmartHome
  198. },
  199. {
  200. // new line
  201. test: e => e.key === "Enter",
  202. run: runNewLine
  203. },
  204. {
  205. // complete braces
  206. test: (e, {completeBraces}) => completeBraces[e.key],
  207. run: runCompleteBraces
  208. }
  209. ];
  210.  
  211. const DEFAULT_OPTIONS = {
  212. indentSize: 4,
  213. indentStyle: "TAB",
  214. completeBraces: {
  215. __proto__: null,
  216. "[": "]",
  217. "{": "}",
  218. "(": ")",
  219. "\"": "\"",
  220. "'": "'"
  221. }
  222. };
  223.  
  224. function createCommandExecutor(options = {}) {
  225. options = Object.assign({}, DEFAULT_OPTIONS, options);
  226. function run(event, editorFactory) {
  227. for (const command of COMMANDS) {
  228. if (command.test(event, options)) {
  229. event.preventDefault();
  230. command.run({editor: editorFactory(), options, event});
  231. break;
  232. }
  233. }
  234. }
  235.  
  236. return {run};
  237. }
  238.  
  239. var textareaPlus = {createCommandExecutor};
  240.  
  241. return textareaPlus;
  242.  
  243. }());
  244.  
  245. /* eslint-env browser, greasemonkey */
  246. /* global textareaPlus GM_config */
  247.  
  248. class Editor {
  249. constructor(textarea) {
  250. this.el = textarea;
  251. }
  252.  
  253. getSelectionRange() {
  254. return {
  255. start: this.el.selectionStart,
  256. end: this.el.selectionEnd
  257. };
  258. }
  259.  
  260. setSelectionRange(start, end) {
  261. this.el.setSelectionRange(start, end);
  262. }
  263.  
  264. getCaretPos(collapse = false) {
  265. if (this.el.selectionDirection == "backward" || collapse) {
  266. return this.el.selectionStart;
  267. }
  268. return this.el.selectionEnd;
  269. }
  270. setCaretPos(pos, collapse = false) {
  271. if (collapse) {
  272. this.setSelectionRange(pos, pos);
  273. } else {
  274. var start = this.el.selectionStart,
  275. end = this.el.selectionEnd,
  276. dir = this.el.selectionDirection;
  277. if (dir == "backward") {
  278. [start, end] = [end, start];
  279. dir = "forward";
  280. }
  281. end = pos;
  282. if (end < start) {
  283. [start, end] = [end, start];
  284. dir = "backward";
  285. }
  286. this.el.selectionEnd = end;
  287. this.el.selectionStart = start;
  288. this.el.selectionDirection = dir;
  289. }
  290. }
  291.  
  292. getLineRange(start, end) {
  293. var content = this.getContent(),
  294. i, j;
  295. i = content.lastIndexOf("\n", start - 1) + 1;
  296. j = content.indexOf("\n", end);
  297. if (j < 0) {
  298. j = content.length;
  299. }
  300. return {
  301. start: i,
  302. end: j
  303. };
  304. }
  305.  
  306. getSelectionLineRange() {
  307. var range = this.getSelectionRange();
  308. return this.getLineRange(range.start, range.end);
  309. }
  310.  
  311. getSelectionLine() {
  312. var content = this.getContent(),
  313. range = this.getSelectionLineRange();
  314. return content.slice(range.start, range.end);
  315. }
  316.  
  317. getCurrentLineRange() {
  318. var pos = this.getCaretPos();
  319. return this.getLineRange(pos, pos);
  320. }
  321.  
  322. getCurrentLine() {
  323. var range = this.getCurrentLineRange(),
  324. content = this.getContent();
  325. return content.slice(range.start, range.end);
  326. }
  327.  
  328. getContent() {
  329. return this.el.value;
  330. }
  331.  
  332. getSelection() {
  333. var content = this.getContent(),
  334. range = this.getSelectionRange();
  335. return content.slice(range.start, range.end);
  336. }
  337.  
  338. setRangeText(...args) {
  339. this.el.setRangeText(...args);
  340. }
  341. }
  342.  
  343. var ignoreClassList = ["CodeMirror", "ace_editor"];
  344.  
  345. function validArea(area) {
  346. if (area.nodeName != "TEXTAREA") {
  347. return false;
  348. }
  349.  
  350. if (area.dataset.textareaPlus === "false") {
  351. return false;
  352. }
  353.  
  354. if (area.dataset.textareaPlus === "true") {
  355. return true;
  356. }
  357.  
  358. var node = area, i;
  359. while ((node = node.parentNode) != document.body) {
  360. for (i = 0; i < ignoreClassList.length; i++) {
  361. if (node.classList.contains(ignoreClassList[i])) {
  362. area.dataset.textareaPlus = "false";
  363. return false;
  364. }
  365. }
  366. }
  367.  
  368. area.dataset.textareaPlus = "true";
  369. return true;
  370. }
  371.  
  372. let commandExcutor, styleEl;
  373.  
  374. GM_config.setup({
  375. indentSize: {
  376. label: "Indent size",
  377. type: "number",
  378. default: 4
  379. },
  380. indentStyle: {
  381. label: "Indent style",
  382. type: "radio",
  383. default: "TAB",
  384. options: {
  385. TAB: "Tab",
  386. SPACE: "Space"
  387. }
  388. },
  389. completeBraces: {
  390. label: "Complete braces. One pair per line",
  391. type: "textarea",
  392. default: "[]\n{}\n()"
  393. }
  394. }, () => {
  395. const options = GM_config.get();
  396. options.completeBraces = createMap(options.completeBraces);
  397. commandExcutor = textareaPlus.createCommandExecutor(options);
  398. if (styleEl) styleEl.remove();
  399. styleEl = GM_addStyle(`
  400. textarea {
  401. tab-size: ${options.indentSize};
  402. -moz-tab-size: ${options.indentSize};
  403. -o-tab-size: ${options.indentSize};
  404. }`
  405. );
  406. function createMap(text) {
  407. const map = {__proto__: null};
  408. for (const pair of text.split(/\s+/g)) {
  409. if (pair.length == 2) {
  410. map[pair[0]] = pair[1];
  411. } else if (pair.length != 0) {
  412. alert(`Invalid pair: ${pair}`);
  413. }
  414. }
  415. return map;
  416. }
  417. });
  418.  
  419. window.addEventListener("keydown", function(e){
  420. if (!validArea(e.target) || e.ctrlKey || e.altKey) {
  421. return;
  422. }
  423. if (e.defaultPrevented) {
  424. return;
  425. }
  426.  
  427. commandExcutor.run(e, () => new Editor(e.target));
  428. });