- 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
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 <[email protected]>
// 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
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, "(").replace(/\)/g, ")").replace(/</g, "<").replace(/>/g, ">");
}
return title ? link + ' "' + title + '"' : link;
});
}
})(); // End of closure for wmd.js code