Stack Apps is a question and answer site for apps, scripts, and development with the Stack Exchange API. Join them; it only takes a minute:

Sign up
Here's how it works:
  1. Anybody can ask a question
  2. Anybody can answer
  3. The best answers are voted up and rise to the top

Inspired by the question, "Markdown syntax for <kbd>foo</kbd>", this script adds both a button and a keyboard shortcut to each post's <textarea> (when editing posts).

Press the button or press AltK on the keyboard and <kbd></kbd> will be inserted at the cursor.
If text is selected, it will be wrapped by the <kbd> tags instead.

Tested the script on both Firefox (needs the Greasemonkey add-on) and Chrome (might work for Opera too).

This script in action

To install, click here (Add_kbd_shortcut.user.js install at Github).

To preview the source, See the copy below, or click here (Add_kbd_shortcut.user.js preview at Github).

Known limitations, possible future enhancements:

  1. When pressing the button, the preview does not immediately update. Make any other changes to the text (normal editing), and the preview catches up.
    The keyboard shortcut always updates the preview instantaneously.


  2. If text with trailing space(s) is selected, the spaces get wrapped, with <kbd> too. This is different from the other markup shortcuts.
    EG: If foo  is selected, the wrapped product will be <kbd>foo </kbd>, not <kbd>foo</kbd> .

    Fixed! This shortcut now handles leading and trailing whitespace the same way that other SE shortcuts (Bold, Italic, Code) do.

  3. This shortcut does not reverse itself. For example, if foo is selected, pressing AltK once yields <kbd>foo</kbd>. Pressing it again yields <kbd><kbd>foo</kbd></kbd>.

    Fixed! This shortcut now wraps and unwraps the same way that other SE shortcuts (Bold, Italic, Code) do.

  4. For very low rep users, the newbie AJAX notifications can erase the shortcut button. The keyboard shortcut continues to work though.

  5. Improved button placement? See Manishearth's answer.

Copy of the code for convenience:

// ==UserScript==
// @name            StackExchange, Add kbd shortcut
// @namespace       StackExchange
// @description     Adds a button and a keyboard shortcut (Alt-K) to add <kbd> tags.
// @version         1.1
// @match           *://**
// @match           *://**
// @match           *://**
// @match           *://**
// @match           *://**
// @match           *://**
// @match           *://**
// ==/UserScript==

function AddKbdShortcuts ($) {
    $("textarea.wmd-input").each (AddKbdButtonAsNeeded);

    //.on() not working right!!
    $("textarea.wmd-input").live ("focus",   AddKbdButtonAsNeeded);
    $("textarea.wmd-input").live ("keydown", InsertKbdTagByKeypress);
    $("li.wmd-kbd-button") .live ("click",   InsertKbdTagByClick);

    function AddKbdButtonAsNeeded () {
        var jThis   = $(this);
        if ( ! ("hasKbdBttn") ) {
            //--- Find the button bar and add our button.
            var btnBar  = jThis.prevAll ("div.wmd-button-bar");
            if (btnBar.length) {
                //--- The button bar takes a while to AJAX-in.
                var bbListTimer = setInterval ( function() {
                        var bbList  = btnBar.find ("ul.wmd-button-row");
                        if (bbList.length) {
                            clearInterval (bbListTimer);
                            bbList.append (
                                  '<li class="wmd-button wmd-kbd-button" '
                                + 'title="Keyboard tag &lt;kbd&gt; Alt+K" '
                                + 'style="left: 380px;">'
                                + '<span><kbd style="border: 0px;">kbd</kbd></span></li>'
                   ("hasKbdBttn", true);

    function InsertKbdTagByKeypress (zEvent) {
        //--- On Alt-K, insert the <kbd> set. Ignore all other keys.
        if (zEvent.altKey  &&  zEvent.which == 75) {
            InsertKbdTag (this);
            return false;
        return true;

    function InsertKbdTagByClick (zEvent) {
        //--- From the clicked button, find the matching textarea.
        var targArea    = $(this).parents ("div.wmd-button-bar")
                        .nextAll ("textarea.wmd-input");

        InsertKbdTag (targArea[0]);
        targArea.focus ();
        try {
            //--- This is a utility function that SE currently provides on its pages.
            StackExchange.MarkdownEditor.refreshAllPreviews ();
        catch (e) {
            console.warn ("***Userscript error: refreshAllPreviews() is no longer defined!");

    function InsertKbdTag (node) {
        //--- Wrap selected text or insert at curser.
        var tagLength       = 5;    // Tag is: "<kbd>"
        var oldText         = node.value || node.textContent;
        var newText;
        var iTargetStart    = node.selectionStart;
        var iTargetEnd      = node.selectionEnd;
        var selectedText    = oldText.slice (iTargetStart, iTargetEnd);
        var possWrappedTxt;
        try {
            //--- Lazyman's overrun checking...
            possWrappedTxt  = oldText.slice (
                                iTargetStart - tagLength,
                                iTargetEnd   + tagLength + 1
        catch (e) {
            possWrappedTxt  = "Text can't be wrapped, cause we overran the string.";

        /*--- Is the current selection wrapped?  If so, just unwrap it.
            This works the same way as SE's bold, italic, code, etc...
            "]text["                --> "<kbd>]text[</kbd>"
            "<kbd>]text[</kbd>"     --> "]text["
            "]<kbd>text</kbd>["     --> "<kbd>]<kbd>text</kbd>[</kbd>"

            Except that:
            "]["                    --> "<kbd>][</kbd>"
            "<kbd>][</kbd>"         --> "]["
            with no placeholder text.
            Note that `]` and `[` denote the selected text here.
        if (possWrappedTxt  &&
            selectedText    == possWrappedTxt.replace (/^<kbd>((?:.|\n|\r)*)<\/kbd>$/, "$1")
        ) {
            iTargetStart   -= tagLength;
            iTargetEnd     += tagLength + 1;
            newText         = oldText.slice (0, iTargetStart)
                            + selectedText + oldText.slice (iTargetEnd)
            iTargetEnd      = iTargetStart + selectedText.length;
        else {
            /*--- Here we will wrap the selection in our tags, but there is one extra
                condition.  We don't want to wrap leading or trailing whitespace.
            var trimSelctd  = selectedText.match (/^(\s*)(\S?(?:.|\n|\r)*\S)(\s*)$/)
                            || ["", "", "", ""]
            if (trimSelctd.length != 4) {
                console.warn ("***Userscript error: unexpected failure of whitespace RE.");
            else {
                newText         = trimSelctd[1]     //-- Leading whitespace, if any.
                                + '<kbd>' + trimSelctd[2] + '</kbd>'
                                + trimSelctd[3]     //-- Trailing whitespace, if any.
                newText         = oldText.slice (0, iTargetStart)
                                + newText + oldText.slice (iTargetEnd)
                iTargetStart   += tagLength + trimSelctd[1].length;
                iTargetEnd     += tagLength - trimSelctd[3].length;

        //console.log (newText);
        node.value          = newText;
        //--- After using spelling corrector, this gets buggered, hence the multiple sets.
        node.textContent    = newText;

        //--- Have to reset the selection, since we overwrote the text.
        node.selectionStart = iTargetStart;
        node.selectionEnd   = iTargetEnd;

withPages_jQuery (AddKbdShortcuts);

function withPages_jQuery (NAMED_FunctionToRun) {
    //--- Use named functions for clarity and debugging...
    var funcText        = NAMED_FunctionToRun.toString ();
    var funcName        = funcText.replace (/^function\s+(\w+)\s*\((.|\n|\r)+$/, "$1");
    var script          = document.createElement ("script");
    script.textContent  = funcText + "\n\n";
    script.textContent += 'jQuery(document).ready( function () {' + funcName + '(jQuery);} );';
    document.body.appendChild (script);
share|improve this question
Nice work, Brock! – Mathias Bynens Apr 24 '12 at 7:10
@MathiasBynens, Thanks! Note that I just uploaded a new version that resolves most of the known limitations and makes the shortcut perform the same as SE's shortcuts. – Brock Adams Apr 24 '12 at 23:49
Nice script! Can you make the toolbar button looks like a key labelled kbd? Like <kbd>kbd</kbd> It could just be a hosted image - I think all the other buttons are hosted images anyway. – ADTC Jan 4 '14 at 21:11
@BrockAdams After some tryout in Chrome Inspector, I found that you simply need to change the text [kbd] to <kbd>kbd</kbd> or <kbd style="border: 0px;">kbd</kbd> and SE's CSS will automatically format it to look like a key labelled kbd. It looks very nice... Give it a try! (Download modified version from Dropbox) – ADTC Jan 27 '14 at 3:57
Thanks @ADTC. Updated script here and at GitHub. – Brock Adams Jan 27 '14 at 4:30
You're welcome. During your next major change, you could also update it to: <span><kbd>k</kbd></span>. Looks more like a proper toolbar button :) (dropbox script is updated). I also think it should be between the Image button and the separator that follows it (Manishearth's improvement?). – ADTC Jan 27 '14 at 8:32
Wish I knew about this before.. A couple of other users and I wrote a script which does exactly this.. (I even asked some questions on SO which @BrockAdams kindly answered). Anyway, looks great :) – gandalf3 Jul 24 '14 at 7:33
@gandalf3, It's all good. But where's a patent troll when you need them? :) – Brock Adams Jul 24 '14 at 8:20
I removed my previous comment as I tackled the issue on this answer. I'd apreciate if you could take a look and tell what you think about using the StackExchange object that way. – brasofilo Sep 29 '14 at 12:09
@brasofilo, you may need to open a new question. I'm not sure what you're asking but that approach seems like it could have timing problems (StackExchange.options.routeName may not be ready when the script fires). Also, the shown code may be overkill, almost the same info can be had by testing location.pathname. – Brock Adams Sep 29 '14 at 20:50
ok, Brock, done – brasofilo Sep 29 '14 at 21:26
I'm not sure anymore why do we need this jQuery wrapping if $.fn.jquery runs on the root... I'd suggest matching only /questions* and /review*. – brasofilo Oct 6 '14 at 8:18
@brasofilo, this script was written before Greasemonkey royally screwed up its sandbox. Also, it runs on a variety of platforms and so doesn't rely on @grant none mode (which you'd be wise to avoid). ... The path restrictions are not necessary as the script is lightweight. In this case they would only double the number of @match lines and make the script more brittle to SE changes. – Brock Adams Oct 6 '14 at 17:40
Há, thanks for your wisdom, man. – brasofilo Oct 6 '14 at 18:47
up vote 9 down vote accepted

Why not use this to add the button? It preserves the integrity of other buttons, and ensures that a button does not get added twice (for example, when in the review section, it will only add the buttons to the editors which don't have it.

//params: text: text of button
//params: callback: a function which takes id of textarea as argument(useful in review section etc when there are multiple textareas) and does the magic encoded in the button
//identify: a unique identifier (for each type of button), that ensures that buttons are not added twice
//pic: optional background pic
function addbutton(text,callback,identify,pic){
    //Callback must take id of textarea as argument.
            //add code for background-position of span as well later
            btn='<li class="wmd-button" style="left: '+px+'px; " onclick="'+callback+'(\''+tid+'\')"><span style="background-image:url('+pic+');">'+text+'</span></li>'; //TODO: do via jQuery
        this.className+=" canhasbutton"+identify;

Then you add a live listener for this (you already have, it seems).

It just makes it extensible :)

share|improve this answer
Okay, I added this to the "to do" list. I'll take a look at this tomorrow; I want to take a break from this script for a bit. – Brock Adams Apr 25 '12 at 0:47
@Brock no problem. Or, if I get time today, I'll forkfix this ;-). Even better, I'll put it in the real git so that you can fork it/see diffs. – Manishearth Apr 25 '12 at 0:49
@Brock I have a working version used in This can be easily extended--add your kbd handler to the //methods section. (via window.kbd=InsertKbdTag, where InsertKbdTag is the function copied from your script), and add an entry to buttonconfig – Manishearth May 4 '12 at 3:31
Okay, thanks. Added this to my SE, minor todo list. – Brock Adams May 4 '12 at 5:45
@Brock note that it interferes with your kbd buttons, so you may want to disable it(my script). Otherwise the buttons will be useless (keyboard shortcuts won't be, though) – Manishearth May 4 '12 at 7:00

I've added a fix to point 3 over here (it's forked)

It deals with <kbd>[selected text]</kbd> and [<kbd>text</kbd>], where the [] denotes selection.

It will not deal with wierd stuff like <kb[d>text</kbd>]. (Could be done using regexes)

share|improve this answer
@Brock You can always remove the corresponding if block :). I myself am new to github, and didn't even know gist existed :P (All my userscripts go here ) You could do diffs and all if this was on github main. Copy that to github main and I'll fork it so that we can get diffs :) – Manishearth Apr 24 '12 at 6:53
I installed Git and tried to slog through it. Can't figure out, yet, how to do anything with the GUI and I don't want to futz with the command-line stuff if I'm only going to use Git every once and a while. ... Anywho, I ended up solving the 3 biggest issues, after carefully examining the behavior of SE's shortcuts. The updated code is still on Gist. (I can now guess why they made an easier to use version of Git ;) .) – Brock Adams Apr 24 '12 at 23:57
@Brock yeah, annoying. I started using Git to host all my userscripts. You can't create stuff online(why not?), but you can edit. I guess gist is good enough :), I don't particularly like Git--but I like to stay organized. – Manishearth Apr 25 '12 at 0:26
@Brock just a note: you may want to position the button based on the $(selector of row).children('.wmd-row').not('.wmd-help-button').last().children('.span').css('‌​background-position-x'). This way other buttons work--I have a bunch of math buttons (not as complete as yours--they have the undo and editor refresh issues), but they position themselves based on the last element. Your key plops itself right on them. – Manishearth Apr 25 '12 at 0:32

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .