kbd formatting button for stackexchange

Adds the ability to quickly insert kbd formatting tags in the SE editor

  1. // ==UserScript==
  2. // @name kbd formatting button for stackexchange
  3. // @description Adds the ability to quickly insert kbd formatting tags in the SE editor
  4. // @namespace http://blender.org
  5. // @include *.stackexchange.com/*
  6. // @include http://stackoverflow.com/*
  7. // @include http://askubuntu.com/*
  8. // @require http://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js
  9. // @version 7
  10. // @grant GM_getValue
  11. // @grant GM_setValue
  12. // ==/UserScript==
  13.  
  14. //Credits to CoDEmanX and iKlsR
  15. //see https://blender.meta.stackexchange.com/q/388/599 for discussion
  16.  
  17.  
  18.  
  19. //calls to GM functions must be outside of injected code, so put them here
  20. function toggle_extra_markdown() {
  21. console.log("checkbox click, was", GM_getValue("extra_markdown", 1))
  22.  
  23. if (GM_getValue("extra_markdown", 1) == 1) {
  24. GM_setValue("extra_markdown", 0);
  25. }
  26. else {
  27. GM_setValue("extra_markdown", 1);
  28. }
  29. }
  30. function get_prefs() {
  31. return GM_getValue("extra_markdown", 1);
  32. }
  33.  
  34. //stuff which will be injected with jquery goes in main:
  35. function main() {
  36. var pref_extra_markdown = 0
  37. console.log("running main!");
  38.  
  39. function startInjection() {
  40.  
  41. //add kbd button when any of these elements are clicked:
  42. $(document).on('click', 'a.edit-post', waitForButtonRow); //inline editing
  43. $(document).on('click', 'input#answer-from-ask', waitForButtonRow); //answering own question in ask questions page
  44. $(document).on('click', 'input[value="Add Another Answer"]', waitForButtonRow); //adding multiple answers
  45. //review editing:
  46. $(document).on('click', 'input[value="Improve"]', waitForButtonRow); //improving suggested edits
  47. $(document).on('click', 'input[value="Edit"]', waitForButtonRow); //editing close voted questions
  48.  
  49. //define keyboard shortcut even handler (Ctrl+Y)
  50. $(document).on('keydown', "textarea.wmd-input", function(e) {
  51. if (e.ctrlKey && (e.which === 89)) {
  52. // turns out SE silently binds Ctrl+Y to redo in addition to Ctrl+Shift+Z; needless to say, us both messing with the content at the same time causes havoc, so we stop SE.
  53. // TODO: this doesn't always seem to work, possibly a race condition? May be best to bind to a different key.
  54. e.stopImmediatePropagation();
  55. insertKbdTag(this);
  56. }
  57. });
  58.  
  59. waitForButtonRow();
  60. }
  61.  
  62. function waitForButtonRow() {
  63. console.log("waiting for button row..")
  64.  
  65. function testForButtonRow() { /*test for a .wmd-button-row every half a second until one is found*/
  66. if (counter < 60) {
  67. if ($(".wmd-button-row").length > 0) { //if button row(s) exist, test each one to see if it already has a kbd button
  68. console.log("found .wmd-button-row");
  69. $(".wmd-button-row").each(function() {console.log("does it have a kbd button? ", $(this).has(".wmd-kbd-button").length);console.log("id", $(this).attr("id"))});
  70. $(".wmd-button-row").each(function() {
  71. if ($(this).has(".wmd-kbd-button").length == 0) { //if no kbd button exists, inject one
  72. console.log("does not contain kbd button, inserting one");
  73. injectButton($(this));
  74. }
  75. });
  76.  
  77. }
  78. else {
  79. setTimeout(testForButtonRow, 500);
  80. counter++;
  81. }
  82. }
  83. else {
  84. console.log("did not find a place to put kbd button within 30 seconds. giving up.");
  85. return;
  86. }
  87. }
  88.  
  89. var counter = 0;
  90. setTimeout(testForButtonRow, 500); //bit of spacer time to allow SE js to execute and add button rows.
  91. //TODO: This causes a potential race condition (if SE js takes longer than 500ms), a better workaround would be nice..
  92. }
  93.  
  94. function injectButton(buttonRow) {
  95. //abandonded attempt to make it work on unity answers:
  96.  
  97. //console.log("host: " + window.location.hostname);
  98. //if (window.location.hostname != "answers.unity3d.com") {
  99. console.log("id-number:" + buttonRow.attr("id").replace(/[^0-9]+/g, ""))
  100. var kbdButtonId = 'wmd-kbd-button' + buttonRow.attr("id").replace(/[^0-9]+/g, "");
  101. /*}
  102. else {
  103. kbdButtonId = "";
  104. }*/
  105.  
  106. var li = $("<li/>");
  107. li.attr('id', kbdButtonId);
  108. li.attr('title', 'Keyboard Shortcut <kbd> Ctrl+Y');
  109. li.addClass('wmd-button wmd-kbd-button');
  110. li.click(function() {
  111. insertKbdTag($(this).parents("div[class='wmd-container']").find("textarea").first()[0]);
  112. });
  113.  
  114. //shuffle existing buttons around so kbd button is the one after image button
  115. var imgButton = $(buttonRow).children("[id^=wmd-image]");
  116. li.insertAfter(imgButton);
  117.  
  118. li.css("left", parseInt(imgButton.css("left")) + 25 + "px"); //put kbd button 25 px after img button
  119. li.nextAll().each(function() {
  120. $(this).css("left", parseInt($(this).css("left")) + 25 + "px"); //move buttons after kbd button farther over
  121. });
  122.  
  123. //Add image element with embedded png icon
  124. var img = $("<img/>").appendTo(li); // Look at that slope :P.. ============> \
  125. img.attr('src', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABUAAAAVCAMAAACeyVWk\
  126. AAAAUVBMVEUAAADMzMz6%2BvrQ0NDS0tL39%2Ff%2F%2F%2F8AAAAAAAAAAADZ2dlGRkYzMzPc3Nz09PSHh4\
  127. eOjo7R0dF5eXmYmJhOTk7t7e06OjrAwMC7u7tiYmLHx8fiGhLAAAAACnRSTlMd%2F%2F%2F%2Fcv%2F%2FAiQ\
  128. FjE%2F%2BXQAAAHdJREFUGNOd0LsawyAIQGHQGi0EL0l6ff8HLV83MFPO4PDLApAhom2UDEBhsSUCGGnxhQion\
  129. yadwmsqaz%2FR%2FcN11gOP76SRU2%2BTtp6Qq9PKq%2FZ2%2BmJdvG1Ot10fej6shvAfu7JxDGeXFErT1QVuMt\
  130. AWpUAG8lruP7kVCZBoOBuAAAAAAElFTkSuQmCC');
  131.  
  132.  
  133.  
  134. //define RMB preferences menu
  135. $(li).on("contextmenu", function(e) {
  136. e.preventDefault();
  137. console.log("started creating context menu. pref_extra_markdown =", pref_extra_markdown)
  138. /*check if a preference menu already exists*/
  139. console.log("contextmenu.length: " + $("#kbd-context-menu").length)
  140.  
  141. if ($("#kbd-context-menu").length < 1) { //ensure context menu doesn't already exist
  142.  
  143. //console.log("contextmenu")
  144. var div = $("<div>").appendTo($(li).parent());
  145. div.attr("id", "kbd-context-menu")
  146. var pOffset = $(li).parent().offset();
  147. div.css({"position": "absolute", "left": (e.pageX-pOffset.left)+5 + "px", "top": (e.pageY-pOffset.top) + "px",
  148. "background-color": "rgba(0,0,0,.7)",
  149. "color": "#f8f8f8",
  150. "padding": "5px",
  151. "padding-top": "1px",
  152. "border-radius": "5px",
  153. "box-shadow": "5px 5px 10px rgba(0,0,0,.7)"});
  154.  
  155. var ul = $("<ul>").appendTo(div);
  156.  
  157. ul.css({"list-style": "none",
  158. "margin": "3px",
  159. "cursor": "default"});
  160.  
  161. //styling for headings, links
  162. ul.append("<li id='kbd_info_links'>");
  163. $("#kbd_info_links").html("<a href='https://blender.meta.stackexchange.com/a/391/599' title='Go to meta post for discussion and feedback'>About</a>").css({"font-size": "6pt"});
  164. ul.append("<li id='kbd_context_title'>");
  165. $("#kbd_context_title").html("Preferences:<br><hr>").css({"font-weight": "bold"});
  166. $("#kbd_context_title hr").css({"margin": "0", "background-color": "rgba(200,200,200,.2)"});
  167.  
  168. //TODO stylize checkbox
  169.  
  170. ul.append("<li id='entry1'>");
  171. $("#entry1").html("Extra markdown <input type='checkbox' />");
  172. $("#entry1").attr("title", "Insert mouse and modifier key icons");
  173. $("#entry1 > input").css({"margin": "0"});
  174. //console.log("div height: " + div.css("height"));
  175. div.css({"top": (e.pageY-pOffset.top) - parseInt(div.css("height")) });
  176.  
  177. //bind mouse sensors to the menu so it goes away on mouse off:
  178. var vanish_delay = setTimeout(function() {$("#kbd-context-menu").fadeOut(500,function() {$(this).remove()})}, 1500);
  179. div.mouseleave(function() {
  180. vanish_delay = setTimeout(function() {$("#kbd-context-menu").fadeOut(500,function() {$(this).remove()})}, 500);
  181. })
  182. div.mouseenter(function() {
  183. console.log("on context menu");
  184. clearTimeout(vanish_delay);
  185. })
  186.  
  187. /*store preferences*/
  188. if (typeof get_prefs === "function") { //for normal chrome extensions get_prefs will be outside of scope
  189. console.log("toggle_markdown:", get_prefs());
  190. if (get_prefs() == 1) {
  191. $("#entry1 > input").prop("checked", 1);
  192. }
  193. }
  194. else { //if being run as chrome extension, use normal variable instead
  195. console.log("get_prefs not found, probably running as chrome extension.", "WARNING: preferences won't be saved accross page loads")
  196. if (pref_extra_markdown == 1) {
  197. $("#entry1 > input").prop("checked", 1);
  198. }
  199. }
  200.  
  201. //bind mouse click sensor to the checkbox:
  202. if (typeof toggle_extra_markdown === "function") {
  203. $("#entry1 > input").click(toggle_extra_markdown)
  204. }
  205. else {
  206. $("#entry1 > input").click(function(){pref_extra_markdown ^= 1}) //toggle non persistent var with xor operator
  207. }
  208. }
  209. else {
  210. $("#kbd-context-menu").remove() //right clicking on the icon when there is an existing context menu will remove it
  211. }
  212. console.log("finished creating context menu. pref_extra_markdown =", pref_extra_markdown)
  213. });
  214. }
  215.  
  216. function insertKbdTag(txta) {
  217.  
  218. if (txta.selectionStart == null) return;
  219.  
  220. var start = txta.selectionStart;
  221. var end = txta.selectionEnd;
  222. var added = 0;
  223. var chars = txta.value;
  224. console.log("chars: " + chars);
  225.  
  226. /*function to insert mousebutton icon references as needed*/
  227. function insertIcon(txta, mb) {
  228.  
  229. function addRef(ref) { //function to test if image references exists, and add it if it doesn't
  230. if (txta.value.indexOf(ref) < 0) {
  231. post = post + "\n\n " + ref; //insert image reference at end of post
  232. }
  233. }
  234.  
  235. console.log("mb", mb);
  236.  
  237. switch (mb.toUpperCase()) {
  238. case "MW":
  239. addRef("[MW]: http://i.stack.imgur.com/v1vyT.png (Mouse Wheel)");
  240. break;
  241. case "LMB":
  242. addRef("[LMB]: http://i.stack.imgur.com/FwrAW.png (Left Mouse Button)");
  243. break;
  244. case "RMB":
  245. addRef("[RMB]: http://i.stack.imgur.com/LPwD4.png (Right Mouse Button)");
  246. break;
  247. case "MMB":
  248. addRef("[MMB]: http://i.stack.imgur.com/OASpJ.png (Middle Mouse Button)");
  249. break;
  250. case "WIN":
  251. addRef("[WIN]: http://i.imgur.com/AAjIi.png (Windows key)"); //use http://i.stack.imgur.com/DHxcg.png for windows 9x logo
  252. break;
  253. case "LINUX":
  254. addRef("[LINUX]: http://i.stack.imgur.com/X9TZA.png (LINUX5EVAH -CharlesL)");
  255. break;
  256.  
  257. }
  258. }
  259.  
  260. //separate selection from rest of body
  261. var pre = chars.slice(0, start);
  262. var post = chars.slice(end);
  263.  
  264. if (start != end) {
  265. var sel = chars.slice(start, end);
  266. console.log("sel: " + sel);
  267. sel = sel.match(/(?:\S+|\s)/g); //split string around whitespace without deleting whitespace, thanks to this SO post: http://stackoverflow.com/a/24504047/2730823
  268. console.log("sel: " + sel);
  269. //remove extra spaces and replace them with kbd markdown
  270. //var lastElement = ""; //holds previous element
  271. var wasSpace = 0; //tracks if last element was a space
  272. var endSpaces = 0; //needed for special end cases
  273. var endSpace = 0;
  274. var refined_markdown = "";
  275.  
  276. for (var char = 0; char < sel.length; char++) {
  277.  
  278. console.log("element " + char + ": " + "'" + sel[char] + "'")
  279. //if current this element is a space, check to see if it should be replaced with a kbd
  280. if (sel[char] == " ") {
  281. //if previous element was not a space, replace space with kbd
  282. if (wasSpace != 1 && char != 0) {
  283. sel.splice(char, 1, '</kbd><kbd>');
  284. //added += 10;
  285. wasSpace = 1;
  286. endSpace = char;
  287. }
  288. else {
  289. //console.log("asdf42")
  290. //console.log(sel.join(""))
  291. sel.splice(char, 1); //remove extra space
  292. //console.log(sel.join(""))
  293. wasSpace = 1;
  294. char--; //go back one element
  295. }
  296. }
  297. else {
  298. wasSpace = 0;
  299. }
  300. if (wasSpace == 1) {
  301. endSpaces ++;
  302. }
  303. else {
  304. endSpaces = 0;
  305. }
  306.  
  307. //test if get_prefs is defined, and if it is test if GM_value "extra markdown" is 1. If get_prefs is not defined, use the non-persistent variable:
  308. if (((typeof get_prefs === "function") ? get_prefs() : pref_extra_markdown) == 1 ) {
  309. //console.log("element: " + sel[char])
  310. switch(sel[char].toLowerCase()) {
  311. case "control":
  312. case "ctrl":
  313. refined_markdown = "&#9096; Ctrl";
  314. break;
  315. case "alternate":
  316. case "alt":
  317. refined_markdown = "&#9095; Alt";
  318. break;
  319. case "shift":
  320. refined_markdown = "&#8679; Shift";
  321. break;
  322. case "tab":
  323. refined_markdown = "&#8633; Tab";
  324. break;
  325. case "delete":
  326. case "del":
  327. refined_markdown = "&#8998; Delete";
  328. break;
  329. case "enter":
  330. case "return":
  331. refined_markdown = "&#9166; Enter";
  332. break;
  333. case "backspace":
  334. refined_markdown = "&#10229; Backspace";
  335. break;
  336. case "pageup":
  337. case "pgup":
  338. refined_markdown = "&#8670; Page up";
  339. break;
  340. case "pagedown":
  341. case "pgdn":
  342. refined_markdown = "&#8671; Page down";
  343. break;
  344. case "printscreen":
  345. refined_markdown = "&#9113; Print Screen";
  346. break;
  347. case "up":
  348. refined_markdown = "&#8593; Up arrow";
  349. break;
  350. case "left":
  351. refined_markdown = "&#8592; Left arrow";
  352. break;
  353. case "right":
  354. refined_markdown = "&#8594; Right arrow";
  355. break;
  356. case "down":
  357. refined_markdown = "&#8595; Down arrow";
  358. break;
  359. case "caps":
  360. case "capslock":
  361. refined_markdown = "&#8682; Caps Lock"; //maybe use &#8684; instead?
  362. break;
  363. case "win":
  364. case "windows":
  365. case "windowskey":
  366. case "winkey":
  367. insertIcon(txta, "WIN");
  368. refined_markdown = "![Windows key][WIN]";
  369. break;
  370. case "super":
  371. case "linux":
  372. case "linuxkey":
  373. case "tuxkey":
  374. insertIcon(txta, "LINUX");
  375. refined_markdown = "![Linux key][LINUX]";
  376. break;
  377. case "meta":
  378. refined_markdown = "&#9670; Meta";
  379. break;
  380.  
  381.  
  382. //mac thingies
  383. case "command":
  384. case "cmd":
  385. refined_markdown = "&#8984; Cmd";
  386. break;
  387. case "option":
  388. case "opt":
  389. refined_markdown = "&#8997; Opt";
  390. break;
  391.  
  392.  
  393. //mouse things
  394. case "wheel":
  395. case "scrollwheel":
  396. case "mousewheel":
  397. case "mw":
  398. insertIcon(txta, "MW");
  399. refined_markdown = "![MW][MW] MW";
  400. break;
  401. case "mmb":
  402. insertIcon(txta, "MMB");
  403. refined_markdown = "![MMB][MMB] MMB";
  404. break;
  405. case "lmb":
  406. insertIcon(txta, "LMB");
  407. refined_markdown = "![LMB][LMB] LMB";
  408. break;
  409. case "rmb":
  410. refined_markdown = "![RMB][RMB] RMB";
  411. insertIcon(txta, "RMB");
  412. break;
  413. }
  414. console.log("refined_markdown: " + refined_markdown)
  415. console.log("refined_markdown.length: " + refined_markdown.length)
  416. if (refined_markdown.length > 0) {
  417. //added += refined_markdown.length;
  418. sel.splice(char, 1, refined_markdown);
  419. refined_markdown = "";
  420. }
  421. }
  422. }
  423. //handle end case separatly; if there is more than 1 space at the end, the last array item is '</kbd><kbd>'
  424. //that will result in an extra <kbd> pair, so remove it.
  425. if (endSpaces > 0) {
  426. sel.splice(endSpace, 1);
  427. }
  428.  
  429. }
  430. else { /*if there is no selection, assign sel to an array so that sel.join returns ""*/
  431. var sel = ["",];
  432. }
  433. //put everything back together again
  434. txta.value = pre + "<kbd>" + sel.join("") + "</kbd>" + post;
  435. added = sel.join("").length + 11
  436. //TODO, this is broken. Need to update cursor position calculation
  437. txta.selectionStart = txta.selectionEnd = pre.length + ((start == end) ? 5 : added); //remove the selection and move
  438.  
  439. $(txta).focus();
  440.  
  441. updateMarkdownPreview(txta);
  442.  
  443. /*
  444. // jQuery-way doesn't work :(
  445. var evt = $.Event('keydown');
  446. evt.which = 17;
  447. evt.keyCode = 17; // Ctrl
  448. $(txta).trigger(e);
  449.  
  450. // another failing attempt
  451. $(txta).trigger({
  452. type: "keydown",
  453. which : 17
  454. });
  455. */
  456. }
  457.  
  458. //function to force update the live markdown render
  459. function updateMarkdownPreview(element) {
  460.  
  461. var keyboardEvent = document.createEvent("KeyboardEvent");
  462. var initMethod = typeof keyboardEvent.initKeyboardEvent !== 'undefined' ? "initKeyboardEvent" : "initKeyEvent";
  463.  
  464. /*keyboardEvent[initMethod](
  465. "keydown", // event type : keydown, keyup, keypress
  466. true, // bubbles
  467. true, // cancelable
  468. window, // viewArg: should be window
  469. false, // ctrlKeyArg
  470. false, // altKeyArg
  471. false, // shiftKeyArg
  472. false, // metaKeyArg
  473. 17, // keyCodeArg : unsigned long the virtual key code, else 0
  474. 0 // charCodeArgs : unsigned long the Unicode character associated with the depressed key, else 0
  475. );
  476. element.dispatchEvent(keyboardEvent);*/
  477.  
  478. //horrible hack so undo after inserting kbd tags only removes kbd tags
  479. //TODO not sure why this works, need to investigate at some point..
  480. keyboardEvent[initMethod](
  481. "keydown", // event type : keydown, keyup, keypress
  482. true, // bubbles
  483. true, // cancelable
  484. document.defaultView, // viewArg: should be window
  485. false, // ctrlKeyArg
  486. false, // altKeyArg
  487. false, // shiftKeyArg
  488. false, // metaKeyArg
  489. 66, // keyCodeArg : unsigned long the virtual key code, else 0
  490. 0 // charCodeArgs : unsigned long the Unicode character associated with the depressed key, else 0
  491. );
  492. element.dispatchEvent(keyboardEvent);
  493. keyboardEvent[initMethod](
  494. "keydown", // event type : keydown, keyup, keypress
  495. true, // bubbles
  496. true, // cancelable
  497. document.defaultView, // viewArg: should be window
  498. false, // ctrlKeyArg
  499. false, // altKeyArg
  500. false, // shiftKeyArg
  501. false, // metaKeyArg
  502. 8, // keyCodeArg : unsigned long the virtual key code, else 0
  503. 0 // charCodeArgs : unsigned long the Unicode character associated with the depressed key, else 0
  504. );
  505. element.dispatchEvent(keyboardEvent);
  506.  
  507. }
  508.  
  509.  
  510. startInjection() //call initial startup function (bind keyboard shortcuts, etc.)
  511. }
  512.  
  513.  
  514. //get jquery on chrome, thanks to this SO post: http://stackoverflow.com/a/12751531/2730823
  515. if (typeof jQuery === "function") {
  516. console.log ("Running with local copy of jQuery!");
  517. main (jQuery);
  518. }
  519. else {
  520. console.log ("fetching jQuery from some 3rd-party server.");
  521. add_jQuery (main, "1.7.2");
  522. }
  523.  
  524. function add_jQuery (callbackFn, jqVersion) {
  525. var jqVersion = jqVersion || "1.7.2";
  526. var D = document;
  527. var targ = D.getElementsByTagName ('head')[0] || D.body || D.documentElement;
  528. var scriptNode = D.createElement ('script');
  529. scriptNode.src = 'http://ajax.googleapis.com/ajax/libs/jquery/'
  530. + jqVersion
  531. + '/jquery.min.js'
  532. ;
  533. scriptNode.addEventListener ("load", function () {
  534. var scriptNode = D.createElement ("script");
  535. scriptNode.textContent =
  536. 'var gm_jQuery = jQuery.noConflict (true);\n'
  537. + '(' + callbackFn.toString () + ')(gm_jQuery);'
  538. ;
  539. targ.appendChild (scriptNode);
  540. }, false);
  541. targ.appendChild (scriptNode);
  542. }