Take the 2-minute tour ×
Stack Apps is a question and answer site for apps, scripts, and development with the Stack Exchange API. It's 100% free, no registration required.
  • Shortcut Ctrl + L to add links to a comment.
  • Define URL templates to make it easier to insert links to documentation.
  • Shortcut Ctrl + L to remove a link in a comment ([caret>|](link) -> caret>|).
  • Recognize certain phrases, and pre-fill the URL box (edit the phraseToURL method).

Screenshot

Insert Hyperlink Link Link Link

Edit the source code of the script to configure it (edit this question to make selection easier).


// ==UserScript==
// @name        Stack Exchange enhanced Ctrl + L
// @namespace   Rob W
// @description Adds Ctrl + L to comments; URL templates
// @version     1.0.3
// @match       http://stackoverflow.com/*
// @match       http://meta.stackoverflow.com/*
// @match       http://superuser.com/*
// @match       http://meta.superuser.com/*
// @match       http://serverfault.com/*
// @match       http://meta.serverfault.com/*
// @match       http://askubuntu.com/*
// @match       http://meta.askubuntu.com/*
// @match       http://*.stackexchange.com/*
// @match       http://answers.onstartups.com/*
// @match       http://meta.answers.onstartups.com/*
// @match       http://stackapps.com/*
// @grant       none
// ==/UserScript==

// (c) 2013 Rob W <gwnRob@gmail.com>
// Released to the public domain
// History:
// 30 May 2013 - Initial release: Ctrl + L for comments & URL templates.
//  9 Jun 2013 - Automatic recognition of patterns, Ctrl + L to undo link in comments, insert URL on press of Enter
// Part of the code (at the end, starting at "courtesyOfStackOverflow") were taken from dev.meta.stackoverflow.com

/*jshint browser:true*/
'use strict';

// Usage: http://docs.site.com/    (placeholder text surrounded by three spaces)   .html
var urls = [
    // Chrome extensions
    'https://developer.chrome.com/extensions/   (api name)   .html',
    'https://developer.chrome.com/extensions/   (api name)   .html#method-   (method name)   ',
    'https://developer.chrome.com/extensions/   (api name)   .html#event-   (event name)   ',

    // Firefox addon SDK
    'https://addons.mozilla.org/en-US/developers/docs/sdk/latest/modules/   (eg sdk/page-mod)   .html',

    // MDN: CSS / JS
//    'https://developer.mozilla.org/en-US/docs/Web/API/   (eg. document)   ',
//    'https://developer.mozilla.org/en-US/docs/Web/CSS/   (eg. height or :hover)   ',
//    'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/   (eg. Array/map)   ',

    // Web platform: HTML
//    'http://docs.webplatform.org/wiki/html/elements/   ()   ',

    // jQuery
    'http://api.jquery.com/   ()   ',

    // Can I use?
    'http://caniuse.com/   (search or feat)   '
];

function formatURL(template_url, replacer) { // replacer = function(full_match, placeholder)
    return template_url.replace(/   \((.*?)\)   /g, replacer);
}
function phraseToURL(text) {
    text = text.replace(/^[\s`]+|[\s`]+$/g, '');// Trim space and backticks
    text = text.replace(/\s*\([\S\s]*?\)/, ''); // Trim parentheses and arguments
    var parts = text.split('.');
    var url = '';
    if (parts.shift() === 'chrome') {
        if (parts.length < 1) return; // chrome. ( nothing )
        var api = '';
        var suffix = '';
        if (parts[0] == 'experimental') { // chrome.experimental. (any API can be experimental)
            parts.shift();
            api += 'experimental.';
        }
        if (parts[0] == 'devtools') { // chrome.devtools
            parts.shift();
            api += 'devtools' + (parts.length ? '.' : '');
        } else if (parts[0] == 'input' || parts[0] == 'systemInfo') {
            api += parts.shift() + '.';
            if (!parts.length) return; // second part is required
        }
        if (parts.length) { // actual API name, if available
            api += parts.shift();
        }
        if (parts.length) {
            var name = parts.shift();
            if (/^on[A-Z]/.test(name)) {
                suffix = '#event-' + name;
            } else if (api == 'storage') {
                if (parts.length == 1) // chrome.storage.(sync/local)
                    suffix = '#property-' + name;
                else if (/^[A-Z]/.test(parts[0])) // chrome.storage.(sync/local).CONSTANT
                    suffix = '#property-' + name + '-' + parts[0];
                else // chrome.storage.(sync/local).method
                    suffix = '#method-StorageArea-' + parts[0];
            } else if (parts.length === 0 && !/^(lastError$|id$|inIncognitoContext$)/.test(name)) {
                // Not chrome.(runtime/extension).lastError
                //     chrome.runtime.id
                //     chrome.extension.inIncognitoContext
                suffix = '#method-' + name;
            } else {
                suffix = '#property-' + name + (parts.length ? '-' + parts.join('-') : '');
            }
        }
        if (api) {
            return 'https://developer.chrome.com/extensions/' + api + '.html' + suffix;
        }
    }

}
var cssTemplate = (function() {/*
.ctrl-l-box {
    // Your preferences:

    max-height: 400px;
    top: 200px;
    // WMD box has a width of 400px
    width: 800px;
    margin-left: -200px;
    padding: 10px;

    position: absolute;
    background-color: white;
    text-align: left;
    overflow-y: auto;
}
.ctrl-l-link-row a {
    float: right;
}
.ctrl-l-param {
    width: 6em;
    padding: 0 3px;
    height: 1.2em;
}
*/}).toString()
    .replace(/^[\S\s]*?\/\*\s+/, '') // Strip  function() { /*
    .replace(/\s*\*\/\s*\}\s*$/, '') // Strip  */ }
    .replace(/^\s*\/\/.*/gm, ''); // Strip "comments"

// Add Ctrl + L functionality
document.addEventListener('keydown', function(event) {
    if (event.keyCode != 76 /* L */ || !event.ctrlKey || event.altKey || event.shiftKey || event.metaKey)
        return; // Not (just) Ctrl + L
    if (event.target.nodeName.toUpperCase() != 'TEXTAREA')
        return; // Not an input field
    if (event.target.id.indexOf('wmd-input') === 0) { // starts with wmd-input
        handleWMDInput(event.target);
        return;
    }
    // At this point, assume a comment
    handleComment(event.target);
    event.preventDefault();
}, false);

var _commentDisabledTimestamp = 0; // Avoid submission of comment after setting link
function temporaryDisableCommentSubmission() { _commentDisabledTimestamp = 300 + Date.now(); }
document.addEventListener('keyup', function(event) {
    if (event.keyCode != 13)
        return; // Not Enter
    if (event.target.name !== 'comment' || event.target.nodeName.toUpperCase() != 'TEXTAREA')
        return; // Not a comment field
    if (Date.now() < _commentDisabledTimestamp) {
        event.preventDefault();
        event.stopPropagation();
    }
}, true);

function handleWMDInput(wmdInput) {
    var selectedValue = wmdInput.value.substring(wmdInput.selectionStart, wmdInput.selectionEnd);
    setTimeout(handleWMDInput_, 0, selectedValue);
}
function handleWMDInput_(selectedValue) {
    var wmddialog = document.querySelector('.wmd-prompt-dialog');
    var urlbox = wmddialog.querySelector('form input[type="text"]');

    var setURL = function(url) {
        urlbox.value = url;
    };
    var extraBox = createExtraBox(setURL);
    onEnterPressOk(extraBox, wmddialog, setURL);
    wmddialog.appendChild(extraBox);

    // Reposition the container
    var marginTop = parseFloat(wmddialog.style.marginTop);
    wmddialog.style.marginTop = (marginTop - extraBox.offsetHeight/2) + 'px';

    // Auto-suggestion for common formats
    var suggestedURL = phraseToURL(selectedValue);
    if (suggestedURL) setURL(suggestedURL);
}
function onEnterPressOk(extraBox, wmddialog, setURL) {
    extraBox.addEventListener('keyup', function(event) {
        if (event.keyCode == 13 && /\bctrl-l-param\b/.test(event.target.className)) {
            var url = elemToURL(event.target);
            if (url) {
                setURL(url);
                // Click OK
                wmddialog.querySelector('input[type="button"][value="OK"]').click();
            }
        }
    }, false);
}
function handleComment(textarea) {
    var value = textarea.value;
    var selectedValue, existingLink;
    var selectionStart = textarea.selectionStart;
    var selectionEnd = textarea.selectionEnd;

    var extractLink = function() {
        selectedValue = value.substring(selectionStart, selectionEnd);
        existingLink = /^(\s*)\[(.*?)\]\(.*?\)(\s*)$/.exec(selectedValue);
    };
    extractLink();
    if (!existingLink) {
        if (value.charAt(selectionStart - 1) == '[' && value.substr(selectionEnd, 2) == '](') {
            selectionStart -= 1;
            selectionEnd = value.indexOf(')', selectionEnd) + 1; // Assume no parentheses in URL
            if (selectionEnd > 0) extractLink();
        }
    }

    if (existingLink) {
        textarea.value = value.substring(0, selectionStart) +
                         existingLink[1] + existingLink[2] + existingLink[3] +
                         value.substring(selectionEnd);
        textarea.selectionStart = selectionStart + existingLink[1].length;
        textarea.selectionEnd = textarea.selectionStart + existingLink[2].length;
    } else {
        courtesyOfStackOverflow(textarea, temporaryDisableCommentSubmission);
        handleWMDInput(textarea);
    }
}

function createExtraBox(setURL) {
    var extraBox = document.createElement('div');
    extraBox.className = 'ctrl-l-box';
    extraBox.innerHTML = createBoxContents();
    var choose = function(event) {
        var url = elemToURL(event.target);
        if (url) setURL(url);
    };
    extraBox.addEventListener('click', choose, false);
    extraBox.addEventListener('input', choose, false);
    addStyleIfNeeded();
    return extraBox;
}
function createBoxContents() {

    // Uses global `urls` = array of strings

    var escapeHTML = function(s) {
        return s.replace(/&/g, '&amp;')
                .replace(/</g, '&lt;')
                .replace(/>/g, '&gt;')
                .replace(/"/g, '&quot;')
                .replace(/'/g, '&#39;');
    };
    return urls.map(function(url) {
        var url_escaped = escapeHTML(url);
        var html = '<div class="ctrl-l-link-row" data-url="' + url_escaped + '">';
        html += '<span>';
        html += formatURL(url_escaped, function(full_match, placeholder) {
            // Placeholder is already a HTML-escaped string
            return '<input type="text" placeholder="' + placeholder + '" title="' + placeholder + '" class="ctrl-l-param">';
        });
        html += '</span>';
        html += '<a href="' + url_escaped + '">Link</a>';
        html += '</div>';
        return html;
    }).join('\n');
}
function elemToURL(elem) {
    while ( elem && !elem.classList.contains('ctrl-l-link-row') )
        elem = elem.parentNode;
    if (!elem || !elem.classList.contains('ctrl-l-link-row'))
        return;
    var template_url = elem.dataset.url;

    var fields = elem.querySelectorAll('.ctrl-l-param');
    var i = 0;
    var url = formatURL(template_url, function() {
        return fields[i++].value;
    });
    elem.querySelector('a[href]').href = url;
    return url;
}

function addStyleIfNeeded() {
    if (!cssTemplate) return;
    var style = document.createElement('style');
    style.appendChild(document.createTextNode(cssTemplate));
    document.head.appendChild(style);
    cssTemplate = null; // Insert just once
}


var courtesyOfStackOverflow;
(function() {
    // Wrapper to use SO's code:
    courtesyOfStackOverflow = function(textField, postProcessing) {
        var selectionStart = textField.selectionStart;
        var selectionEnd = textField.selectionEnd;
        var value = textField.value;
        var selectedText = value.substring(selectionStart, selectionEnd) || defaultsStrings.linkdescription;

        var linkDefaultText = 'http://';

        var linkEnteredCallback = function(link) {
            background.parentNode.removeChild(background);

            if (link) {
                var newStuff = '[' + selectedText + '](' + properlyEncoded(link) + ')';
                textField.value = value.substring(0, selectionStart) + newStuff + value.slice(selectionEnd);
                textField.selectionStart = selectionStart + 1; // 1 = '['
                textField.selectionEnd = selectionStart + 1 + selectedText.length;
            } else {
                textField.selectionStart = selectionStart;
                textField.selectionEnd = selectionEnd;
            }
            textField.focus();
            postProcessing();
        };

        var background = ui.createBackground();
        ui.prompt(defaultsStrings.linkdialog, linkDefaultText, linkEnteredCallback);
    };

    // Courtesy of Stack Overflow, http://dev.meta.stackoverflow.com/content/js/wmd.js
    var ui = {};
    var position = {};
    var util = {};
    var defaultsStrings = {
            link: "Hyperlink <a> Ctrl+L",
            linkdescription: "enter link description here",
            linkdialog: "<p><b>Insert Hyperlink</b></p><p>http://example.com/ \"optional title\"</p>",
    };

    var doc = document;
    position.getTop = function (elem, isInner) {
        var result = elem.offsetTop;
        if (!isInner) {
            while ( (elem = elem.offsetParent) ) {
                result += elem.offsetTop;
            }
        }
        return result;
    };

    position.getHeight = function (elem) {
        return elem.offsetHeight || elem.scrollHeight;
    };

    position.getWidth = function (elem) {
        return elem.offsetWidth || elem.scrollWidth;
    };

    position.getPageSize = function () {
        var self = window;

        var scrollWidth, scrollHeight;
        var innerWidth, innerHeight;

        // It's not very clear which blocks work with which browsers.
        if (self.innerHeight && self.scrollMaxY) {
            scrollWidth = doc.body.scrollWidth;
            scrollHeight = self.innerHeight + self.scrollMaxY;
        }
        else if (doc.body.scrollHeight > doc.body.offsetHeight) {
            scrollWidth = doc.body.scrollWidth;
            scrollHeight = doc.body.scrollHeight;
        }
        else {
            scrollWidth = doc.body.offsetWidth;
            scrollHeight = doc.body.offsetHeight;
        }

        if (self.innerHeight) {
            // Non-IE browser
            innerWidth = self.innerWidth;
            innerHeight = self.innerHeight;
        }
        else if (doc.documentElement && doc.documentElement.clientHeight) {
            // Some versions of IE (IE 6 w/ a DOCTYPE declaration)
            innerWidth = doc.documentElement.clientWidth;
            innerHeight = doc.documentElement.clientHeight;
        }
        else if (doc.body) {
            // Other versions of IE
            innerWidth = doc.body.clientWidth;
            innerHeight = doc.body.clientHeight;
        }

        var maxWidth = Math.max(scrollWidth, innerWidth);
        var maxHeight = Math.max(scrollHeight, innerHeight);
        return [maxWidth, maxHeight, innerWidth, innerHeight];
    };

    util.addEvent = function(elem, event, listener) { elem.addEventListener(event, listener, false); };
    util.removeEvent = function(elem, event, listener) { elem.removeEventListener(event, listener, false); };

    ui.createBackground = function () {

        var background = doc.createElement("div"),
            style = background.style;

        background.className = "wmd-prompt-background";

        style.position = "absolute";
        style.top = "0";

        style.zIndex = "1000";

        style.opacity = "0.5";

        var pageSize = position.getPageSize();
        style.height = pageSize[1] + "px";

        style.left = "0";
        style.width = "100%";

        doc.body.appendChild(background);
        return background;
    };

    ui.prompt = function (text, defaultInputText, callback) {

        // These variables need to be declared at this level since they are used
        // in multiple functions.
        var dialog;         // The dialog box.
        var input;         // The text box where you enter the hyperlink.


        if (defaultInputText === undefined) {
            defaultInputText = "";
        }

        // Used as a keydown event handler. Esc dismisses the prompt.
        // Key code 27 is ESC.
        var checkEscape = function (key) {
            var code = (key.charCode || key.keyCode);
            if (code === 27) {
                close(true);
            }
        };

        // Dismisses the hyperlink input box.
        // isCancel is true if we don't care about the input text.
        // isCancel is false if we are going to keep the text.
        var close = function (isCancel) {
            util.removeEvent(doc.body, "keydown", checkEscape);
            var text = input.value;

            if (isCancel) {
                text = null;
            }
            else {
                // Fixes common pasting errors.
                text = text.replace(/^http:\/\/(https?|ftp):\/\//, '$1://');
                if (!/^(?:https?|ftp):\/\//.test(text))
                    text = 'http://' + text;
            }

            dialog.parentNode.removeChild(dialog);

            callback(text);
            return false;
        };



        // Create the text input box form/window.
        var createDialog = function () {

            // The main dialog box.
            dialog = doc.createElement("div");
            dialog.className = "wmd-prompt-dialog";
            dialog.style.padding = "10px;";
            dialog.style.position = "fixed";
            dialog.style.width = "400px";
            dialog.style.zIndex = "1001";

            // The dialog text.
            var question = doc.createElement("div");
            question.innerHTML = text;
            question.style.padding = "5px";
            dialog.appendChild(question);

            // The web form container for the text box and buttons.
            var form = doc.createElement("form"),
                style = form.style;
            form.onsubmit = function () { return close(false); };
            style.padding = "0";
            style.margin = "0";
            style.cssFloat = "left";
            style.width = "100%";
            style.textAlign = "center";
            style.position = "relative";
            dialog.appendChild(form);

            // The input text box
            input = doc.createElement("input");
            input.type = "text";
            input.value = defaultInputText;
            style = input.style;
            style.display = "block";
            style.width = "80%";
            style.marginLeft = style.marginRight = "auto";
            form.appendChild(input);

            // The ok button
            var okButton = doc.createElement("input");
            okButton.type = "button";
            okButton.onclick = function () { return close(false); };
            okButton.value = "OK";
            style = okButton.style;
            style.margin = "10px";
            style.display = "inline";
            style.width = "7em";


            // The cancel button
            var cancelButton = doc.createElement("input");
            cancelButton.type = "button";
            cancelButton.onclick = function () { return close(true); };
            cancelButton.value = "Cancel";
            style = cancelButton.style;
            style.margin = "10px";
            style.display = "inline";
            style.width = "7em";

            form.appendChild(okButton);
            form.appendChild(cancelButton);

            util.addEvent(doc.body, "keydown", checkEscape);
            dialog.style.top = "50%";
            dialog.style.left = "50%";
            dialog.style.display = "block";
            doc.body.appendChild(dialog);

            // This has to be done AFTER adding the dialog to the form if you
            // want it to be centered.
            dialog.style.marginTop = -(position.getHeight(dialog) / 2) + "px";
            dialog.style.marginLeft = -(position.getWidth(dialog) / 2) + "px";

        };

        // Why is this in a zero-length timeout?
        // Is it working around a browser bug?
        setTimeout(function () {

            createDialog();

            var defTextLen = defaultInputText.length;
            if (input.selectionStart !== undefined) {
                input.selectionStart = 0;
                input.selectionEnd = defTextLen;
            }
            else if (input.createTextRange) {
                var range = input.createTextRange();
                range.collapse(false);
                range.moveStart("character", -defTextLen);
                range.moveEnd("character", defTextLen);
                range.select();
            }

            input.focus();
        }, 0);
    };

    // takes the line as entered into the add link/as image dialog and makes
    // sure the URL and the optinal title are "nice".
    function properlyEncoded(linkdef) {
        return linkdef.replace(/^\s*(.*?)(?:\s+"(.+)")?\s*$/, function (wholematch, link, title) {
            link = link.replace(/\?.*$/, function (querypart) {
                return querypart.replace(/\+/g, " "); // in the query string, a plus and a space are identical
            });
            link = decodeURIComponent(link); // unencode first, to prevent double encoding
            link = encodeURI(link).replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29');
            link = link.replace(/\?.*$/, function (querypart) {
                return querypart.replace(/\+/g, "%2b"); // since we replaced plus with spaces in the query part, all pluses that now appear where originally encoded
            });
            if (title) {
                title = title.trim ? title.trim() : title.replace(/^\s*/, "").replace(/\s*$/, "");
                title = title.replace(/"/g, "quot;").replace(/\(/g, "&#40;").replace(/\)/g, "&#41;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
            }
            return title ? link + ' "' + title + '"' : link;
        });
    }
})(); // End of closure for wmd.js code
share|improve this question
    
Feel free to share your URL templates in an answer. –  Rob W May 29 '13 at 19:44
add comment

You must log in to answer this question.

Browse other questions tagged .