Last active
August 29, 2015 14:27
-
-
Save danielmai/db61177c563dbdbb8375 to your computer and use it in GitHub Desktop.
No Ctrl-K on Udacity Discussions
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name No Ctrl-K for Udacity Discussions! | |
// @namespace http://your.homepage/ | |
// @version 0.1 | |
// @description enter something useful | |
// @author You | |
// @match https://discussions.udacity.com/* | |
// @grant none | |
// ==/UserScript== | |
// needs Markdown.Converter.js at the moment | |
// To insert extra buttons: | |
// | |
// Before this file is required, define a PagedownCustom object. Give it an attribtue of insertButtons, which is an array | |
// of the buttons you want to insert. For example: | |
// | |
// window.PagedownCustom = { | |
// insertButtons: [ | |
// { | |
// id: 'wmd-bark', | |
// description: 'Bark', | |
// execute: function() { | |
// return alert('woof!'); | |
// } | |
// } | |
// ] | |
// }; | |
// | |
// To extend actions: | |
// | |
// window.PagedownCustom = { | |
// customActions: { | |
// "doBlockquote": function(chunk, postProcessing, oldDoBlockquote) { | |
// console.log('custom blockquote called!'); | |
// return oldDoBlockquote.call(this, chunk, postProcessing); | |
// } | |
// } | |
// }; | |
(function () { | |
var util = {}, | |
position = {}, | |
ui = {}, | |
doc = window.document, | |
re = window.RegExp, | |
nav = window.navigator, | |
SETTINGS = { lineLength: 72 }; | |
var defaultsStrings = { | |
bold: "Strong <strong> Ctrl+B", | |
boldexample: "strong text", | |
italic: "Emphasis <em> Ctrl+I", | |
italicexample: "emphasized text", | |
link: "Hyperlink <a> Ctrl+L", | |
linkdescription: "enter link description here", | |
linkdialog: "<p><b>Insert Hyperlink</b></p><p>http://example.com/ \"optional title\"</p>", | |
quote: "Blockquote <blockquote> Ctrl+Q", | |
quoteexample: "Blockquote", | |
code: "Code Sample <pre><code> Ctrl+K", | |
codeexample: "enter code here", | |
image: "Image <img> Ctrl+G", | |
imagedescription: "enter image description here", | |
imagedialog: "<p><b>Insert Image</b></p><p>http://example.com/images/diagram.jpg \"optional title\"<br><br>Need <a href='http://www.google.com/search?q=free+image+hosting' target='_blank'>free image hosting?</a></p>", | |
olist: "Numbered List <ol> Ctrl+O", | |
ulist: "Bulleted List <ul> Ctrl+U", | |
litem: "List item", | |
heading: "Heading <h1>/<h2> Ctrl+H", | |
headingexample: "Heading", | |
hr: "Horizontal Rule <hr> Ctrl+R", | |
undo: "Undo - Ctrl+Z", | |
redo: "Redo - Ctrl+Y", | |
redomac: "Redo - Ctrl+Shift+Z", | |
help: "Markdown Editing Help" | |
}; | |
// ------------------------------------------------------------------- | |
// YOUR CHANGES GO HERE | |
// | |
// I've tried to localize the things you are likely to change to | |
// this area. | |
// ------------------------------------------------------------------- | |
// The default text that appears in the dialog input box when entering | |
// links. | |
var imageDefaultText = "http://"; | |
var linkDefaultText = "http://"; | |
// ------------------------------------------------------------------- | |
// END OF YOUR CHANGES | |
// ------------------------------------------------------------------- | |
// options, if given, can have the following properties: | |
// options.helpButton = { handler: yourEventHandler } | |
// options.strings = { italicexample: "slanted text" } | |
// `yourEventHandler` is the click handler for the help button. | |
// If `options.helpButton` isn't given, not help button is created. | |
// `options.strings` can have any or all of the same properties as | |
// `defaultStrings` above, so you can just override some string displayed | |
// to the user on a case-by-case basis, or translate all strings to | |
// a different language. | |
// | |
// For backwards compatibility reasons, the `options` argument can also | |
// be just the `helpButton` object, and `strings.help` can also be set via | |
// `helpButton.title`. This should be considered legacy. | |
// | |
// The constructed editor object has the methods: | |
// - getConverter() returns the markdown converter object that was passed to the constructor | |
// - run() actually starts the editor; should be called after all necessary plugins are registered. Calling this more than once is a no-op. | |
// - refreshPreview() forces the preview to be updated. This method is only available after run() was called. | |
Markdown.Editor = function (markdownConverter, idPostfix, options) { | |
options = options || {}; | |
if (typeof options.handler === "function") { //backwards compatible behavior | |
options = { helpButton: options }; | |
} | |
options.strings = options.strings || {}; | |
if (options.helpButton) { | |
options.strings.help = options.strings.help || options.helpButton.title; | |
} | |
var getString = function (identifier) { return options.strings[identifier] || defaultsStrings[identifier]; } | |
idPostfix = idPostfix || ""; | |
var hooks = this.hooks = new Markdown.HookCollection(); | |
hooks.addNoop("onPreviewRefresh"); // called with no arguments after the preview has been refreshed | |
hooks.addNoop("postBlockquoteCreation"); // called with the user's selection *after* the blockquote was created; should return the actual to-be-inserted text | |
hooks.addFalse("insertImageDialog"); /* called with one parameter: a callback to be called with the URL of the image. If the application creates | |
* its own image insertion dialog, this hook should return true, and the callback should be called with the chosen | |
* image url (or null if the user cancelled). If this hook returns false, the default dialog will be used. | |
*/ | |
this.getConverter = function () { return markdownConverter; } | |
var that = this, | |
panels; | |
this.run = function () { | |
if (panels) | |
return; // already initialized | |
panels = new PanelCollection(options.containerElement); | |
var commandManager = new CommandManager(hooks, getString); | |
var previewManager = new PreviewManager(markdownConverter, panels, function () { hooks.onPreviewRefresh(); }); | |
var undoManager, uiManager; | |
if (false && !/\?noundo/.test(doc.location.href)) { | |
undoManager = new UndoManager(function () { | |
previewManager.refresh(); | |
if (uiManager) // not available on the first call | |
uiManager.setUndoRedoButtonStates(); | |
}, panels); | |
this.textOperation = function (f) { | |
undoManager.setCommandMode(); | |
f(); | |
that.refreshPreview(); | |
} | |
} | |
uiManager = new UIManager(idPostfix, panels, undoManager, previewManager, commandManager, options.helpButton, getString); | |
uiManager.setUndoRedoButtonStates(); | |
var forceRefresh = that.refreshPreview = function () { previewManager.refresh(true); }; | |
forceRefresh(); | |
}; | |
} | |
// before: contains all the text in the input box BEFORE the selection. | |
// after: contains all the text in the input box AFTER the selection. | |
function Chunks() { } | |
// startRegex: a regular expression to find the start tag | |
// endRegex: a regular expresssion to find the end tag | |
Chunks.prototype.findTags = function (startRegex, endRegex) { | |
var chunkObj = this; | |
var regex; | |
if (startRegex) { | |
regex = util.extendRegExp(startRegex, "", "$"); | |
this.before = this.before.replace(regex, | |
function (match) { | |
chunkObj.startTag = chunkObj.startTag + match; | |
return ""; | |
}); | |
regex = util.extendRegExp(startRegex, "^", ""); | |
this.selection = this.selection.replace(regex, | |
function (match) { | |
chunkObj.startTag = chunkObj.startTag + match; | |
return ""; | |
}); | |
} | |
if (endRegex) { | |
regex = util.extendRegExp(endRegex, "", "$"); | |
this.selection = this.selection.replace(regex, | |
function (match) { | |
chunkObj.endTag = match + chunkObj.endTag; | |
return ""; | |
}); | |
regex = util.extendRegExp(endRegex, "^", ""); | |
this.after = this.after.replace(regex, | |
function (match) { | |
chunkObj.endTag = match + chunkObj.endTag; | |
return ""; | |
}); | |
} | |
}; | |
// If remove is false, the whitespace is transferred | |
// to the before/after regions. | |
// | |
// If remove is true, the whitespace disappears. | |
Chunks.prototype.trimWhitespace = function (remove) { | |
var beforeReplacer, afterReplacer, that = this; | |
if (remove) { | |
beforeReplacer = afterReplacer = ""; | |
} else { | |
beforeReplacer = function (s) { that.before += s; return ""; } | |
afterReplacer = function (s) { that.after = s + that.after; return ""; } | |
} | |
this.selection = this.selection.replace(/^(\s*)/, beforeReplacer).replace(/(\s*)$/, afterReplacer); | |
}; | |
Chunks.prototype.skipLines = function (nLinesBefore, nLinesAfter, findExtraNewlines) { | |
if (nLinesBefore === undefined) { | |
nLinesBefore = 1; | |
} | |
if (nLinesAfter === undefined) { | |
nLinesAfter = 1; | |
} | |
nLinesBefore++; | |
nLinesAfter++; | |
var regexText; | |
var replacementText; | |
// chrome bug ... documented at: http://meta.stackoverflow.com/questions/63307/blockquote-glitch-in-editor-in-chrome-6-and-7/65985#65985 | |
if (navigator.userAgent.match(/Chrome/)) { | |
"X".match(/()./); | |
} | |
this.selection = this.selection.replace(/(^\n*)/, ""); | |
this.startTag = this.startTag + re.$1; | |
this.selection = this.selection.replace(/(\n*$)/, ""); | |
this.endTag = this.endTag + re.$1; | |
this.startTag = this.startTag.replace(/(^\n*)/, ""); | |
this.before = this.before + re.$1; | |
this.endTag = this.endTag.replace(/(\n*$)/, ""); | |
this.after = this.after + re.$1; | |
if (this.before) { | |
regexText = replacementText = ""; | |
while (nLinesBefore--) { | |
regexText += "\\n?"; | |
replacementText += "\n"; | |
} | |
if (findExtraNewlines) { | |
regexText = "\\n*"; | |
} | |
this.before = this.before.replace(new re(regexText + "$", ""), replacementText); | |
} | |
if (this.after) { | |
regexText = replacementText = ""; | |
while (nLinesAfter--) { | |
regexText += "\\n?"; | |
replacementText += "\n"; | |
} | |
if (findExtraNewlines) { | |
regexText = "\\n*"; | |
} | |
this.after = this.after.replace(new re(regexText, ""), replacementText); | |
} | |
}; | |
// end of Chunks | |
function firstByClass(doc, containerElement, className) { | |
var elements = doc.getElementsByClassName(className); | |
if (elements && elements.length) { | |
return elements[0]; | |
} | |
} | |
// A collection of the important regions on the page. | |
// Cached so we don't have to keep traversing the DOM. | |
function PanelCollection(containerElement) { | |
this.buttonBar = firstByClass(doc, containerElement, 'wmd-button-bar'); | |
this.preview = firstByClass(doc, containerElement, 'wmd-preview'); | |
this.input = firstByClass(doc, containerElement, 'wmd-input'); | |
}; | |
// Returns true if the DOM element is visible, false if it's hidden. | |
// Checks if display is anything other than none. | |
util.isVisible = function (elem) { | |
return window.getComputedStyle(elem, null).getPropertyValue("display") !== "none"; | |
}; | |
// Adds a listener callback to a DOM element which is fired on a specified | |
// event. | |
util.addEvent = function (elem, event, listener) { | |
var wrapped = function() { | |
var wrappedArgs = Array.prototype.slice.call(arguments); | |
Ember.run(function() { | |
listener.apply(this, wrappedArgs); | |
}); | |
}; | |
elem.addEventListener(event, wrapped, false); | |
}; | |
// Removes a listener callback from a DOM element which is fired on a specified | |
// event. | |
util.removeEvent = function (elem, event, listener) { | |
elem.removeEventListener(event, listener, false); | |
}; | |
// Converts \r\n and \r to \n. | |
util.fixEolChars = function (text) { | |
text = text.replace(/\r\n/g, "\n"); | |
text = text.replace(/\r/g, "\n"); | |
return text; | |
}; | |
// Extends a regular expression. Returns a new RegExp | |
// using pre + regex + post as the expression. | |
// Used in a few functions where we have a base | |
// expression and we want to pre- or append some | |
// conditions to it (e.g. adding "$" to the end). | |
// The flags are unchanged. | |
// | |
// regex is a RegExp, pre and post are strings. | |
util.extendRegExp = function (regex, pre, post) { | |
if (pre === null || pre === undefined) { | |
pre = ""; | |
} | |
if (post === null || post === undefined) { | |
post = ""; | |
} | |
var pattern = regex.toString(); | |
var flags; | |
// Replace the flags with empty space and store them. | |
pattern = pattern.replace(/\/([gim]*)$/, function (wholeMatch, flagsPart) { | |
flags = flagsPart; | |
return ""; | |
}); | |
// Remove the slash delimiters on the regular expression. | |
pattern = pattern.replace(/(^\/|\/$)/g, ""); | |
pattern = pre + pattern + post; | |
return new re(pattern, flags); | |
} | |
// UNFINISHED | |
// The assignment in the while loop makes jslint cranky. | |
// I'll change it to a better loop later. | |
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 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; | |
} | |
innerWidth = self.innerWidth; | |
innerHeight = self.innerHeight; | |
var maxWidth = Math.max(scrollWidth, innerWidth); | |
var maxHeight = Math.max(scrollHeight, innerHeight); | |
return [maxWidth, maxHeight, innerWidth, innerHeight]; | |
}; | |
// Handles pushing and popping TextareaStates for undo/redo commands. | |
// I should rename the stack variables to list. | |
function UndoManager(callback, panels) { | |
var undoObj = this; | |
var undoStack = []; // A stack of undo states | |
var stackPtr = 0; // The index of the current state | |
var mode = "none"; | |
var lastState; // The last state | |
var timer; // The setTimeout handle for cancelling the timer | |
var inputStateObj; | |
// Set the mode for later logic steps. | |
var setMode = function (newMode, noSave) { | |
if (mode != newMode) { | |
mode = newMode; | |
if (!noSave) { | |
saveState(); | |
} | |
} | |
if (mode != "moving") { | |
timer = setTimeout(refreshState, 1); | |
} | |
else { | |
inputStateObj = null; | |
} | |
}; | |
var refreshState = function (isInitialState) { | |
inputStateObj = new TextareaState(panels, isInitialState); | |
timer = undefined; | |
}; | |
this.setCommandMode = function () { | |
mode = "command"; | |
saveState(); | |
timer = setTimeout(refreshState, 0); | |
}; | |
this.canUndo = function () { | |
return stackPtr > 1; | |
}; | |
this.canRedo = function () { | |
if (undoStack[stackPtr + 1]) { | |
return true; | |
} | |
return false; | |
}; | |
// Removes the last state and restores it. | |
this.undo = function () { | |
if (undoObj.canUndo()) { | |
if (lastState) { | |
// What about setting state -1 to null or checking for undefined? | |
lastState.restore(); | |
lastState = null; | |
} | |
else { | |
undoStack[stackPtr] = new TextareaState(panels); | |
undoStack[--stackPtr].restore(); | |
if (callback) { | |
callback(); | |
} | |
} | |
} | |
mode = "none"; | |
panels.input.focus(); | |
refreshState(); | |
}; | |
// Redo an action. | |
this.redo = function () { | |
if (undoObj.canRedo()) { | |
undoStack[++stackPtr].restore(); | |
if (callback) { | |
callback(); | |
} | |
} | |
mode = "none"; | |
panels.input.focus(); | |
refreshState(); | |
}; | |
// Push the input area state to the stack. | |
var saveState = function () { | |
var currState = inputStateObj || new TextareaState(panels); | |
if (!currState) { | |
return false; | |
} | |
if (mode == "moving") { | |
if (!lastState) { | |
lastState = currState; | |
} | |
return; | |
} | |
if (lastState) { | |
if (undoStack[stackPtr - 1].text != lastState.text) { | |
undoStack[stackPtr++] = lastState; | |
} | |
lastState = null; | |
} | |
undoStack[stackPtr++] = currState; | |
undoStack[stackPtr + 1] = null; | |
if (callback) { | |
callback(); | |
} | |
}; | |
var handleCtrlYZ = function (event) { | |
var handled = false; | |
if ((event.ctrlKey || event.metaKey) && !event.altKey) { | |
var keyCode = event.charCode; | |
var keyCodeChar = String.fromCharCode(keyCode); | |
switch (keyCodeChar.toLowerCase()) { | |
case "y": | |
if (!event.shiftKey) { | |
undoObj.redo(); | |
handled = true; | |
} | |
break; | |
case "z": | |
if (!event.shiftKey) { | |
undoObj.undo(); | |
} | |
else { | |
undoObj.redo(); | |
} | |
handled = true; | |
break; | |
} | |
} | |
if (handled) { | |
if (event.preventDefault) { | |
event.preventDefault(); | |
} | |
if (window.event) { | |
window.event.returnValue = false; | |
} | |
return; | |
} | |
}; | |
// Set the mode depending on what is going on in the input area. | |
var handleModeChange = function (event) { | |
if (!event.ctrlKey && !event.metaKey) { | |
var keyCode = event.keyCode; | |
if ((keyCode >= 33 && keyCode <= 40) || (keyCode >= 63232 && keyCode <= 63235)) { | |
// 33 - 40: page up/dn and arrow keys | |
// 63232 - 63235: page up/dn and arrow keys on safari | |
setMode("moving"); | |
} | |
else if (keyCode == 8 || keyCode == 46 || keyCode == 127) { | |
// 8: backspace | |
// 46: delete | |
// 127: delete | |
setMode("deleting"); | |
} | |
else if (keyCode == 13) { | |
// 13: Enter | |
setMode("newlines"); | |
} | |
else if (keyCode == 27) { | |
// 27: escape | |
setMode("escape"); | |
} | |
else if ((keyCode < 16 || keyCode > 20) && keyCode != 91) { | |
// 16-20 are shift, etc. | |
// 91: left window key | |
// I think this might be a little messed up since there are | |
// a lot of nonprinting keys above 20. | |
setMode("typing"); | |
} | |
} | |
}; | |
var setEventHandlers = function () { | |
util.addEvent(panels.input, "keypress", function (event) { | |
// keyCode 89: y | |
// keyCode 90: z | |
if ((event.ctrlKey || event.metaKey) && !event.altKey && (event.keyCode == 89 || event.keyCode == 90)) { | |
event.preventDefault(); | |
} | |
}); | |
var handlePaste = function () { | |
if (inputStateObj && inputStateObj.text != panels.input.value) { | |
if (timer == undefined) { | |
mode = "paste"; | |
saveState(); | |
refreshState(); | |
} | |
} | |
}; | |
util.addEvent(panels.input, "keydown", handleCtrlYZ); | |
util.addEvent(panels.input, "keydown", handleModeChange); | |
util.addEvent(panels.input, "mousedown", function () { | |
setMode("moving"); | |
}); | |
panels.input.onpaste = handlePaste; | |
panels.input.ondrop = handlePaste; | |
}; | |
var init = function () { | |
setEventHandlers(); | |
refreshState(true); | |
saveState(); | |
}; | |
init(); | |
} | |
// end of UndoManager | |
// The input textarea state/contents. | |
// This is used to implement undo/redo by the undo manager. | |
function TextareaState(panels, isInitialState) { | |
// Aliases | |
var stateObj = this; | |
var inputArea = panels.input; | |
this.init = function () { | |
if (!util.isVisible(inputArea)) { | |
return; | |
} | |
if (!isInitialState && doc.activeElement && doc.activeElement !== inputArea) { // this happens when tabbing out of the input box | |
return; | |
} | |
this.setInputAreaSelectionStartEnd(); | |
this.scrollTop = inputArea.scrollTop; | |
if (!this.text && inputArea.selectionStart || inputArea.selectionStart === 0) { | |
this.text = inputArea.value; | |
} | |
} | |
// Sets the selected text in the input box after we've performed an | |
// operation. | |
this.setInputAreaSelection = function () { | |
if (!util.isVisible(inputArea)) { | |
return; | |
} | |
if (inputArea.selectionStart !== undefined) { | |
inputArea.focus(); | |
inputArea.selectionStart = stateObj.start; | |
inputArea.selectionEnd = stateObj.end; | |
inputArea.scrollTop = stateObj.scrollTop; | |
} | |
else if (doc.selection) { | |
if (doc.activeElement && doc.activeElement !== inputArea) { | |
return; | |
} | |
inputArea.focus(); | |
var range = inputArea.createTextRange(); | |
range.moveStart("character", -inputArea.value.length); | |
range.moveEnd("character", -inputArea.value.length); | |
range.moveEnd("character", stateObj.end); | |
range.moveStart("character", stateObj.start); | |
range.select(); | |
} | |
}; | |
this.setInputAreaSelectionStartEnd = function () { | |
if (inputArea.selectionStart || inputArea.selectionStart === 0) { | |
stateObj.start = inputArea.selectionStart; | |
stateObj.end = inputArea.selectionEnd; | |
} | |
else if (doc.selection) { | |
stateObj.text = util.fixEolChars(inputArea.value); | |
var range = doc.selection.createRange(); | |
var fixedRange = util.fixEolChars(range.text); | |
var marker = "\x07"; | |
var markedRange = marker + fixedRange + marker; | |
range.text = markedRange; | |
var inputText = util.fixEolChars(inputArea.value); | |
range.moveStart("character", -markedRange.length); | |
range.text = fixedRange; | |
stateObj.start = inputText.indexOf(marker); | |
stateObj.end = inputText.lastIndexOf(marker) - marker.length; | |
var len = stateObj.text.length - util.fixEolChars(inputArea.value).length; | |
if (len) { | |
range.moveStart("character", -fixedRange.length); | |
while (len--) { | |
fixedRange += "\n"; | |
stateObj.end += 1; | |
} | |
range.text = fixedRange; | |
} | |
this.setInputAreaSelection(); | |
} | |
}; | |
// Restore this state into the input area. | |
this.restore = function () { | |
if (stateObj.text != undefined && stateObj.text != inputArea.value) { | |
inputArea.value = stateObj.text; | |
} | |
this.setInputAreaSelection(); | |
inputArea.scrollTop = stateObj.scrollTop; | |
}; | |
// Gets a collection of HTML chunks from the inptut textarea. | |
this.getChunks = function () { | |
var chunk = new Chunks(); | |
chunk.before = util.fixEolChars(stateObj.text.substring(0, stateObj.start)); | |
chunk.startTag = ""; | |
chunk.selection = util.fixEolChars(stateObj.text.substring(stateObj.start, stateObj.end)); | |
chunk.endTag = ""; | |
chunk.after = util.fixEolChars(stateObj.text.substring(stateObj.end)); | |
chunk.scrollTop = stateObj.scrollTop; | |
return chunk; | |
}; | |
// Sets the TextareaState properties given a chunk of markdown. | |
this.setChunks = function (chunk) { | |
chunk.before = chunk.before + chunk.startTag; | |
chunk.after = chunk.endTag + chunk.after; | |
this.start = chunk.before.length; | |
this.end = chunk.before.length + chunk.selection.length; | |
this.text = chunk.before + chunk.selection + chunk.after; | |
this.scrollTop = chunk.scrollTop; | |
}; | |
this.init(); | |
}; | |
function PreviewManager(converter, panels, previewRefreshCallback) { | |
var managerObj = this; | |
var timeout; | |
var elapsedTime; | |
var oldInputText; | |
var maxDelay = 3000; | |
var startType = "delayed"; // The other legal value is "manual" | |
var paneContentHeight = function(pane) { | |
var $pane = $(pane); | |
var paneVerticalPadding = parseInt($pane.css("padding-top")) + parseInt($pane.css("padding-bottom")); | |
return pane.scrollHeight - paneVerticalPadding; | |
}; | |
var syncScroll = function(isEdit) { | |
var $preview = $(panels.preview); | |
var $input = $(panels.input); | |
if($input.scrollTop() === 0){ | |
$preview.scrollTop(0); | |
return; | |
} | |
if(($input.height() + $input.scrollTop() + 100) > panels.input.scrollHeight){ | |
// cheat, special case for bottom | |
$preview.scrollTop(panels.preview.scrollHeight); | |
return; | |
} | |
var scrollPosition = $(panels.input).scrollTop(); | |
var factor = panels.preview.scrollHeight / panels.input.scrollHeight; | |
var desired = scrollPosition * factor; | |
$preview.scrollTop(desired + 50); | |
}; | |
var setupScrollSync = function() { | |
var sync = _.throttle(syncScroll, 20); | |
$(panels.input).scroll(function() { | |
sync(); | |
}); | |
}; | |
// Adds event listeners to elements | |
var setupEvents = function (inputElem, listener) { | |
util.addEvent(inputElem, "input", listener); | |
inputElem.onpaste = listener; | |
inputElem.ondrop = listener; | |
util.addEvent(inputElem, "keypress", listener); | |
util.addEvent(inputElem, "keydown", listener); | |
}; | |
var getDocScrollTop = function () { | |
var result = 0; | |
if (window.innerHeight) { | |
result = window.pageYOffset; | |
} | |
else | |
if (doc.documentElement && doc.documentElement.scrollTop) { | |
result = doc.documentElement.scrollTop; | |
} | |
else | |
if (doc.body) { | |
result = doc.body.scrollTop; | |
} | |
return result; | |
}; | |
var makePreviewHtml = function () { | |
// If there is no registered preview panel | |
// there is nothing to do. | |
if (!panels.preview) | |
return; | |
var text = panels.input.value; | |
if (text && text == oldInputText) { | |
return; // Input text hasn't changed. | |
} | |
else { | |
oldInputText = text; | |
} | |
var prevTime = new Date().getTime(); | |
var previewText; | |
previewText = converter.makeHtml(text); | |
// Calculate the processing time of the HTML creation. | |
// It's used as the delay time in the event listener. | |
var currTime = new Date().getTime(); | |
elapsedTime = currTime - prevTime; | |
Ember.run(function() { | |
pushPreviewHtml(previewText); | |
syncScroll(true); | |
}); | |
}; | |
// makePreviewHtml = window.probes.measure(makePreviewHtml, { | |
// before: function(){ window.probes.clear(); }, | |
// name: "makePreview", | |
// after: function(p) { window.probes.clear(); console.log("Total time to preview: " + p.time); } | |
// }); | |
// TODO allow us to inject this in (its our debouncer) | |
var debounce = function(func,wait,trickle) { | |
var timeout = null; | |
return function() { | |
var context = this; | |
var args = arguments; | |
later = function(){ | |
timeout = null; | |
func.apply(context, args); | |
}; | |
if (timeout!=null && trickle) { | |
return; | |
} | |
var currentWait; | |
if (typeof wait == "function") { | |
currentWait = wait(); | |
} else { | |
currentWait = wait; | |
} | |
if (timeout) { Ember.run.cancel(timeout); } | |
timeout = Ember.run.later(later, currentWait); | |
} | |
} | |
makePreviewHtml = debounce(makePreviewHtml, function(){ | |
return Math.min(Math.max((elapsedTime || 1) * 10, 80),1000); | |
}, true); | |
// setTimeout is already used. Used as an event listener. | |
var applyTimeout = function () { | |
if (timeout) { | |
clearTimeout(timeout); | |
timeout = undefined; | |
} | |
if (startType !== "manual") { | |
var delay = 0; | |
if (startType === "delayed") { | |
delay = elapsedTime; | |
} | |
if (delay > maxDelay) { | |
delay = maxDelay; | |
} | |
timeout = setTimeout(makePreviewHtml, delay); | |
} | |
}; | |
var getScaleFactor = function (panel) { | |
if (panel.scrollHeight <= panel.clientHeight) { | |
return 1; | |
} | |
return panel.scrollTop / (panel.scrollHeight - panel.clientHeight); | |
}; | |
this.refresh = function (requiresRefresh) { | |
if (requiresRefresh) { | |
oldInputText = ""; | |
makePreviewHtml(); | |
} | |
else { | |
applyTimeout(); | |
} | |
}; | |
this.processingTime = function () { | |
return elapsedTime; | |
}; | |
var isFirstTimeFilled = true; | |
var nonSuckyBrowserPreviewSet = function (previewText) { | |
panels.preview.innerHTML = previewText; | |
} | |
var previewSetter; | |
var previewSet = function (previewText) { | |
if (previewSetter) | |
return previewSetter(previewText); | |
nonSuckyBrowserPreviewSet(previewText); | |
previewSetter = nonSuckyBrowserPreviewSet; | |
}; | |
var pushPreviewHtml = function (previewText) { | |
var emptyTop = position.getTop(panels.input) - getDocScrollTop(); | |
if (panels.preview) { | |
previewSet(previewText); | |
previewRefreshCallback(); | |
} | |
if (isFirstTimeFilled) { | |
isFirstTimeFilled = false; | |
return; | |
} | |
var fullTop = position.getTop(panels.input) - getDocScrollTop(); | |
window.scrollBy(0, fullTop - emptyTop); | |
}; | |
var init = function () { | |
// TODO: make option to disable. We don't need this in discourse | |
// setupEvents(panels.input, applyTimeout); | |
setupScrollSync(); | |
makePreviewHtml(); | |
}; | |
init(); | |
}; | |
// Creates the background behind the hyperlink text entry box. | |
// And download dialog | |
// Most of this has been moved to CSS but the div creation and | |
// browser-specific hacks remain here. | |
ui.createBackground = function () { | |
var background = doc.createElement("div"), | |
style = background.style; | |
background.className = "wmd-prompt-background"; | |
style.position = "absolute"; | |
style.top = "0"; | |
style.zIndex = "2000"; | |
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; | |
}; | |
// This simulates a modal dialog box and asks for the URL when you | |
// click the hyperlink or image buttons. | |
// | |
// text: The html for the input box. | |
// defaultInputText: The default value that appears in the input box. | |
// callback: The function which is executed when the prompt is dismissed, either via OK or Cancel. | |
// It receives a single argument; either the entered text (if OK was chosen) or null (if Cancel | |
// was chosen). | |
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 = "2001"; | |
// 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); | |
}; | |
function UIManager(postfix, panels, undoManager, previewManager, commandManager, helpOptions, getString) { | |
var inputBox = panels.input, | |
buttons = {}; // buttons.undo, buttons.link, etc. The actual DOM elements. | |
makeSpritedButtonRow(); | |
var keyEvent = "keydown"; | |
var extendedEvents = []; | |
if(window.PagedownCustom){ | |
window.PagedownCustom.appendButtons.concat(window.PagedownCustom.insertButtons).forEach(function(button){ | |
if(button.shortcut){ | |
extendedEvents.push([button.shortcut, button.execute]); | |
} | |
}); | |
} | |
util.addEvent(inputBox, keyEvent, function (key) { | |
// Check to see if we have a button key and, if so execute the callback. | |
if ((key.ctrlKey || key.metaKey) && !key.altKey && !key.shiftKey) { | |
var keyCode = key.charCode || key.keyCode; | |
var keyCodeStr = String.fromCharCode(keyCode).toLowerCase(); | |
for(var i=0; i<extendedEvents.length; i++){ | |
if(keyCodeStr===extendedEvents[i][0]){ | |
extendedEvents[i][1](); | |
} | |
} | |
// switch (keyCodeStr) { | |
// case "b": | |
// doClick(buttons.bold); | |
// break; | |
// case "i": | |
// doClick(buttons.italic); | |
// break; | |
// case "l": | |
// doClick(buttons.link); | |
// break; | |
// case "q": | |
// doClick(buttons.quote); | |
// break; | |
// case "k": | |
// doClick(buttons.code); | |
// break; | |
// case "g": | |
// doClick(buttons.image); | |
// break; | |
// case "o": | |
// doClick(buttons.olist); | |
// break; | |
// case "u": | |
// doClick(buttons.ulist); | |
// break; | |
// case "h": | |
// doClick(buttons.heading); | |
// break; | |
// default: | |
// return; | |
// } | |
if (key.preventDefault) { | |
key.preventDefault(); | |
} | |
if (window.event) { | |
window.event.returnValue = false; | |
} | |
} | |
}); | |
// Perform the button's action. | |
function doClick(button) { | |
inputBox.focus(); | |
if (button && button.textOp) { | |
if (undoManager) { | |
undoManager.setCommandMode(); | |
} | |
var state = new TextareaState(panels); | |
if (!state) { | |
return; | |
} | |
var chunks = state.getChunks(); | |
// Some commands launch a "modal" prompt dialog. Javascript | |
// can't really make a modal dialog box and the WMD code | |
// will continue to execute while the dialog is displayed. | |
// This prevents the dialog pattern I'm used to and means | |
// I can't do something like this: | |
// | |
// var link = CreateLinkDialog(); | |
// makeMarkdownLink(link); | |
// | |
// Instead of this straightforward method of handling a | |
// dialog I have to pass any code which would execute | |
// after the dialog is dismissed (e.g. link creation) | |
// in a function parameter. | |
// | |
// Yes this is awkward and I think it sucks, but there's | |
// no real workaround. Only the image and link code | |
// create dialogs and require the function pointers. | |
var fixupInputArea = function () { | |
inputBox.focus(); | |
if (chunks) { | |
state.setChunks(chunks); | |
} | |
state.restore(); | |
previewManager.refresh(); | |
}; | |
var noCleanup = button.textOp(chunks, fixupInputArea); | |
if (!noCleanup) { | |
fixupInputArea(); | |
} | |
} | |
if (button.execute) { | |
button.execute(undoManager); | |
} | |
}; | |
function setupButton(button, isEnabled) { | |
if (isEnabled) { | |
button.disabled = false | |
if (!button.isHelp) { | |
button.onclick = function () { | |
if (this.onmouseout) { | |
this.onmouseout(); | |
} | |
doClick(this); | |
return false; | |
} | |
} | |
} | |
else { | |
button.disabled = true | |
button.onmouseover = button.onmouseout = button.onclick = function () { }; | |
} | |
} | |
function bindCommand(method) { | |
if (typeof PagedownCustom != "undefined" && PagedownCustom.customActions) { | |
var custom = PagedownCustom.customActions[method]; | |
if (custom) { | |
return function() { | |
var args = Array.prototype.slice.call(arguments); | |
args.push(commandManager[method]); | |
return custom.apply(commandManager, args); | |
}; | |
} | |
} | |
if (typeof method === "string") | |
method = commandManager[method]; | |
return function () { method.apply(commandManager, arguments); } | |
} | |
function makeSpritedButtonRow() { | |
var buttonBar = panels.buttonBar; | |
var buttonRow = document.createElement("div"); | |
buttonRow.className = 'wmd-button-row'; | |
buttonRow = buttonBar.appendChild(buttonRow); | |
var xPosition = 0; | |
var makeButton = function (id, title, textOp) { | |
var button = document.createElement("button"); | |
button.className = "wmd-button " + id; | |
xPosition += 25; | |
button.title = title; | |
// we really should just use jquery here | |
if (button.setAttribute) { | |
button.setAttribute('aria-label', title); | |
} | |
if (textOp) | |
button.textOp = textOp; | |
setupButton(button, true); | |
buttonRow.appendChild(button); | |
return button; | |
}; | |
var makeSpacer = function (num) { | |
var spacer = document.createElement("div"); | |
spacer.className = "wmd-spacer"; | |
spacer.id = "wmd-spacer" + num + postfix; | |
buttonRow.appendChild(spacer); | |
xPosition += 25; | |
} | |
// If we have any buttons to insert, do it! | |
if (typeof PagedownCustom != "undefined") { | |
insertButtons = PagedownCustom.insertButtons | |
if (insertButtons && (insertButtons.length > 0)) { | |
for (var i=0; i<insertButtons.length; i++) { | |
var b = insertButtons[i]; | |
makeButton(b.id, b.description, b.execute) | |
} | |
} | |
} | |
buttons.bold = makeButton("wmd-bold-button", getString("bold"), bindCommand("doBold")); | |
buttons.italic = makeButton("wmd-italic-button", getString("italic"), bindCommand("doItalic")); | |
makeSpacer(1); | |
buttons.link = makeButton("wmd-link-button", getString("link"), bindCommand(function (chunk, postProcessing) { | |
return this.doLinkOrImage(chunk, postProcessing, false); | |
})); | |
buttons.quote = makeButton("wmd-quote-button", getString("quote"), bindCommand("doBlockquote")); | |
buttons.code = makeButton("wmd-code-button", getString("code"), bindCommand("doCode")); | |
buttons.image = makeButton("wmd-image-button", getString("image"), bindCommand(function (chunk, postProcessing) { | |
return this.doLinkOrImage(chunk, postProcessing, true); | |
})); | |
makeSpacer(2); | |
buttons.olist = makeButton("wmd-olist-button", getString("olist"), bindCommand(function (chunk, postProcessing) { | |
this.doList(chunk, postProcessing, true); | |
})); | |
buttons.ulist = makeButton("wmd-ulist-button", getString("ulist"), bindCommand(function (chunk, postProcessing) { | |
this.doList(chunk, postProcessing, false); | |
})); | |
buttons.heading = makeButton("wmd-heading-button", getString("heading"), bindCommand("doHeading")); | |
buttons.hr = makeButton("wmd-hr-button", getString("hr"), bindCommand("doHorizontalRule")); | |
// If we have any buttons to append, do it! | |
if (typeof PagedownCustom != "undefined") { | |
var appendButtons = PagedownCustom.appendButtons | |
if (appendButtons && (appendButtons.length > 0)) { | |
for (var i=0; i< appendButtons.length; i++) { | |
var b = appendButtons[i]; | |
makeButton(b.id, b.description, b.execute) | |
} | |
} | |
} | |
//makeSpacer(3); | |
//buttons.undo = makeButton("wmd-undo-button", getString("undo"), null); | |
//buttons.undo.execute = function (manager) { if (manager) manager.undo(); }; | |
// var redoTitle = /win/.test(nav.platform.toLowerCase()) ? | |
// getString("redo") : | |
// getString("redomac"); // mac and other non-Windows platforms | |
//buttons.redo = makeButton("wmd-redo-button", redoTitle, null); | |
//buttons.redo.execute = function (manager) { if (manager) manager.redo(); }; | |
// if (helpOptions) { | |
// var helpButton = document.createElement("li"); | |
// var helpButtonImage = document.createElement("span"); | |
// helpButton.appendChild(helpButtonImage); | |
// helpButton.className = "wmd-button wmd-help-button"; | |
// helpButton.id = "wmd-help-button" + postfix; | |
// helpButton.isHelp = true; | |
// helpButton.style.right = "0px"; | |
// helpButton.title = getString("help"); | |
// helpButton.onclick = helpOptions.handler; | |
// | |
// setupButton(helpButton, true); | |
// buttonRow.appendChild(helpButton); | |
// buttons.help = helpButton; | |
// } | |
// setUndoRedoButtonStates(); | |
} | |
function setUndoRedoButtonStates() { | |
if (undoManager) { | |
setupButton(buttons.undo, undoManager.canUndo()); | |
setupButton(buttons.redo, undoManager.canRedo()); | |
} | |
}; | |
this.setUndoRedoButtonStates = setUndoRedoButtonStates; | |
} | |
function CommandManager(pluginHooks, getString) { | |
this.hooks = pluginHooks; | |
this.getString = getString; | |
} | |
var commandProto = CommandManager.prototype; | |
// The markdown symbols - 4 spaces = code, > = blockquote, etc. | |
commandProto.prefixes = "(?:\\s{4,}|\\s*>|\\s*-\\s+|\\s*\\d+\\.|=|\\+|-|_|\\*|#|\\s*\\[[^\n]]+\\]:)"; | |
// Remove markdown symbols from the chunk selection. | |
commandProto.unwrap = function (chunk) { | |
var txt = new re("([^\\n])\\n(?!(\\n|" + this.prefixes + "))", "g"); | |
chunk.selection = chunk.selection.replace(txt, "$1 $2"); | |
}; | |
commandProto.wrap = function (chunk, len) { | |
this.unwrap(chunk); | |
var regex = new re("(.{1," + len + "})( +|$\\n?)", "gm"), | |
that = this; | |
chunk.selection = chunk.selection.replace(regex, function (line, marked) { | |
if (new re("^" + that.prefixes, "").test(line)) { | |
return line; | |
} | |
return marked + "\n"; | |
}); | |
chunk.selection = chunk.selection.replace(/\s+$/, ""); | |
}; | |
commandProto.doBold = function (chunk, postProcessing) { | |
return this.doSurroundLines(chunk, postProcessing, 2, this.getString("boldexample")); | |
}; | |
commandProto.doItalic = function (chunk, postProcessing) { | |
return this.doSurroundLines(chunk, postProcessing, 1, this.getString("italicexample")); | |
}; | |
commandProto.doSurroundLines = function(realChunk, postProcessing, nStars, fallbackText) { | |
realChunk.trimWhitespace(); | |
// Look for stars before and after, absorb them into the selection. | |
var starsBefore = /(\**$)/.exec(realChunk.before)[0]; | |
var starsAfter = /(^\**)/.exec(realChunk.after)[0]; | |
realChunk.before = realChunk.before.replace(/(\**$)/, ""); | |
realChunk.after = realChunk.after.replace(/(^\**)/, ""); | |
var lines = (starsBefore + realChunk.selection + starsAfter).split("\n"); | |
// Don't show the fallback text if more than one line is selected, | |
// it's probably a break between paragraphs. | |
if (lines.length > 1) { | |
fallbackText = ""; | |
} | |
for(var i=0; i<lines.length; i++) { | |
// Split before, selection and after up. | |
var lineMatch = lines[i].match(/^(\s*\**)(.*?)(\**\s*)$/); | |
var newChunk = new Chunks(); | |
newChunk.before = lineMatch[1]; | |
newChunk.selection = lineMatch[2]; | |
newChunk.after = lineMatch[3]; | |
this.doSurroundLine(newChunk, postProcessing, nStars, fallbackText); | |
if (lines.length > 1) { | |
lines[i] = newChunk.before + newChunk.selection + newChunk.after; | |
} else { | |
realChunk.startTag = newChunk.before; | |
realChunk.endTag = newChunk.after; | |
lines[i] = newChunk.selection; | |
} | |
} | |
realChunk.selection = lines.join("\n"); | |
}; | |
// chunk: The selected region that will be enclosed with * or ** | |
// nStars: 1 for italics, 2 for bold | |
// fallbackText: If you just click the button without highlighting text, this gets inserted | |
commandProto.doSurroundLine = function (chunk, postProcessing, nStars, fallbackText) { | |
// Get rid of whitespace | |
chunk.trimWhitespace(); | |
var minStars = Math.min(chunk.before.length, chunk.after.length); | |
// Remove stars if we have to since the button acts as a toggle. | |
if ((minStars >= nStars) && (minStars != 2 || nStars != 1)) { | |
chunk.before = chunk.before.replace(re("[*]{" + nStars + "}$", ""), ""); | |
chunk.after = chunk.after.replace(re("^[*]{" + nStars + "}", ""), ""); | |
} | |
else { | |
// In most cases, if you don't have any selected text and click the button | |
// you'll get a selected, marked up region with the default text inserted. | |
if (!chunk.selection && !chunk.after) { | |
chunk.selection = fallbackText; | |
} | |
// Only operate if it's not a blank line | |
if (chunk.selection) { | |
// Add the true markup. | |
var markup = nStars === 1 ? "*" : "**"; | |
chunk.before = chunk.before + markup; | |
chunk.after = markup + chunk.after; | |
} | |
} | |
}; | |
commandProto.stripLinkDefs = function (text, defsToAdd) { | |
text = text.replace(/^[ ]{0,3}\[(\d+)\]:[ \t]*\n?[ \t]*<?(\S+?)>?[ \t]*\n?[ \t]*(?:(\n*)["(](.+?)[")][ \t]*)?(?:\n+|$)/gm, | |
function (totalMatch, id, link, newlines, title) { | |
defsToAdd[id] = totalMatch.replace(/\s*$/, ""); | |
if (newlines) { | |
// Strip the title and return that separately. | |
defsToAdd[id] = totalMatch.replace(/["(](.+?)[")]$/, ""); | |
return newlines + title; | |
} | |
return ""; | |
}); | |
return text; | |
}; | |
commandProto.addLinkDef = function (chunk, linkDef) { | |
var refNumber = 0; // The current reference number | |
var defsToAdd = {}; // | |
// Start with a clean slate by removing all previous link definitions. | |
chunk.before = this.stripLinkDefs(chunk.before, defsToAdd); | |
chunk.selection = this.stripLinkDefs(chunk.selection, defsToAdd); | |
chunk.after = this.stripLinkDefs(chunk.after, defsToAdd); | |
var defs = ""; | |
var regex = /(\[)((?:\[[^\]]*\]|[^\[\]])*)(\][ ]?(?:\n[ ]*)?\[)(\d+)(\])/g; | |
var addDefNumber = function (def) { | |
refNumber++; | |
def = def.replace(/^[ ]{0,3}\[(\d+)\]:/, " [" + refNumber + "]:"); | |
defs += "\n" + def; | |
}; | |
// note that | |
// a) the recursive call to getLink cannot go infinite, because by definition | |
// of regex, inner is always a proper substring of wholeMatch, and | |
// b) more than one level of nesting is neither supported by the regex | |
// nor making a lot of sense (the only use case for nesting is a linked image) | |
var getLink = function (wholeMatch, before, inner, afterInner, id, end) { | |
inner = inner.replace(regex, getLink); | |
if (defsToAdd[id]) { | |
addDefNumber(defsToAdd[id]); | |
return before + inner + afterInner + refNumber + end; | |
} | |
return wholeMatch; | |
}; | |
chunk.before = chunk.before.replace(regex, getLink); | |
if (linkDef) { | |
addDefNumber(linkDef); | |
} | |
else { | |
chunk.selection = chunk.selection.replace(regex, getLink); | |
} | |
var refOut = refNumber; | |
chunk.after = chunk.after.replace(regex, getLink); | |
if (chunk.after) { | |
chunk.after = chunk.after.replace(/\n*$/, ""); | |
} | |
if (!chunk.after) { | |
chunk.selection = chunk.selection.replace(/\n*$/, ""); | |
} | |
chunk.after += "\n\n" + defs; | |
return refOut; | |
}; | |
// 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(/ /g, '%20').replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29'); | |
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; | |
}); | |
} | |
commandProto.doLinkOrImage = function (chunk, postProcessing, isImage) { | |
chunk.trimWhitespace(); | |
chunk.findTags(/\s*!?\[/, /\][ ]?(?:\n[ ]*)?(\[.*?\])?/); | |
var background; | |
if (chunk.endTag.length > 1 && chunk.startTag.length > 0) { | |
chunk.startTag = chunk.startTag.replace(/!?\[/, ""); | |
chunk.endTag = ""; | |
this.addLinkDef(chunk, null); | |
} | |
else { | |
// We're moving start and end tag back into the selection, since (as we're in the else block) we're not | |
// *removing* a link, but *adding* one, so whatever findTags() found is now back to being part of the | |
// link text. linkEnteredCallback takes care of escaping any brackets. | |
chunk.selection = chunk.startTag + chunk.selection + chunk.endTag; | |
chunk.startTag = chunk.endTag = ""; | |
if (/\n\n/.test(chunk.selection)) { | |
this.addLinkDef(chunk, null); | |
return; | |
} | |
var that = this; | |
// The function to be executed when you enter a link and press OK or Cancel. | |
// Marks up the link and adds the ref. | |
var linkEnteredCallback = function (link) { | |
background.parentNode.removeChild(background); | |
if (link !== null) { | |
// ( $1 | |
// [^\\] anything that's not a backslash | |
// (?:\\\\)* an even number (this includes zero) of backslashes | |
// ) | |
// (?= followed by | |
// [[\]] an opening or closing bracket | |
// ) | |
// | |
// In other words, a non-escaped bracket. These have to be escaped now to make sure they | |
// don't count as the end of the link or similar. | |
// Note that the actual bracket has to be a lookahead, because (in case of to subsequent brackets), | |
// the bracket in one match may be the "not a backslash" character in the next match, so it | |
// should not be consumed by the first match. | |
// The "prepend a space and finally remove it" steps makes sure there is a "not a backslash" at the | |
// start of the string, so this also works if the selection begins with a bracket. We cannot solve | |
// this by anchoring with ^, because in the case that the selection starts with two brackets, this | |
// would mean a zero-width match at the start. Since zero-width matches advance the string position, | |
// the first bracket could then not act as the "not a backslash" for the second. | |
chunk.selection = (" " + chunk.selection).replace(/([^\\](?:\\\\)*)(?=[[\]])/g, "$1\\").substr(1); | |
var linkDef = " [999]: " + properlyEncoded(link); | |
var num = that.addLinkDef(chunk, linkDef); | |
chunk.startTag = isImage ? "![" : "["; | |
chunk.endTag = "][" + num + "]"; | |
if (!chunk.selection) { | |
if (isImage) { | |
chunk.selection = that.getString("imagedescription"); | |
} | |
else { | |
chunk.selection = that.getString("linkdescription"); | |
} | |
} | |
} | |
postProcessing(); | |
}; | |
background = ui.createBackground(); | |
if (isImage) { | |
if (!this.hooks.insertImageDialog(linkEnteredCallback)) | |
ui.prompt(this.getString("imagedialog"), imageDefaultText, linkEnteredCallback); | |
} | |
else { | |
ui.prompt(this.getString("linkdialog"), linkDefaultText, linkEnteredCallback); | |
} | |
return true; | |
} | |
}; | |
commandProto.doBlockquote = function (chunk, postProcessing) { | |
chunk.selection = chunk.selection.replace(/^(\n*)([^\r]+?)(\n*)$/, | |
function (totalMatch, newlinesBefore, text, newlinesAfter) { | |
chunk.before += newlinesBefore; | |
chunk.after = newlinesAfter + chunk.after; | |
return text; | |
}); | |
chunk.before = chunk.before.replace(/(>[ \t]*)$/, | |
function (totalMatch, blankLine) { | |
chunk.selection = blankLine + chunk.selection; | |
return ""; | |
}); | |
chunk.selection = chunk.selection.replace(/^(\s|>)+$/, ""); | |
chunk.selection = chunk.selection || this.getString("quoteexample"); | |
// The original code uses a regular expression to find out how much of the | |
// text *directly before* the selection already was a blockquote: | |
/* | |
if (chunk.before) { | |
chunk.before = chunk.before.replace(/\n?$/, "\n"); | |
} | |
chunk.before = chunk.before.replace(/(((\n|^)(\n[ \t]*)*>(.+\n)*.*)+(\n[ \t]*)*$)/, | |
function (totalMatch) { | |
chunk.startTag = totalMatch; | |
return ""; | |
}); | |
*/ | |
// This comes down to: | |
// Go backwards as many lines a possible, such that each line | |
// a) starts with ">", or | |
// b) is almost empty, except for whitespace, or | |
// c) is preceeded by an unbroken chain of non-empty lines | |
// leading up to a line that starts with ">" and at least one more character | |
// and in addition | |
// d) at least one line fulfills a) | |
// | |
// Since this is essentially a backwards-moving regex, it's susceptible to | |
// catstrophic backtracking and can cause the browser to hang; | |
// see e.g. http://meta.stackoverflow.com/questions/9807. | |
// | |
// Hence we replaced this by a simple state machine that just goes through the | |
// lines and checks for a), b), and c). | |
var match = "", | |
leftOver = "", | |
line; | |
if (chunk.before) { | |
var lines = chunk.before.replace(/\n$/, "").split("\n"); | |
var inChain = false; | |
for (var i = 0; i < lines.length; i++) { | |
var good = false; | |
line = lines[i]; | |
inChain = inChain && line.length > 0; // c) any non-empty line continues the chain | |
if (/^>/.test(line)) { // a) | |
good = true; | |
if (!inChain && line.length > 1) // c) any line that starts with ">" and has at least one more character starts the chain | |
inChain = true; | |
} else if (/^[ \t]*$/.test(line)) { // b) | |
good = true; | |
} else { | |
good = inChain; // c) the line is not empty and does not start with ">", so it matches if and only if we're in the chain | |
} | |
if (good) { | |
match += line + "\n"; | |
} else { | |
leftOver += match + line; | |
match = "\n"; | |
} | |
} | |
if (!/(^|\n)>/.test(match)) { // d) | |
leftOver += match; | |
match = ""; | |
} | |
} | |
chunk.startTag = match; | |
chunk.before = leftOver; | |
// end of change | |
if (chunk.after) { | |
chunk.after = chunk.after.replace(/^\n?/, "\n"); | |
} | |
chunk.after = chunk.after.replace(/^(((\n|^)(\n[ \t]*)*>(.+\n)*.*)+(\n[ \t]*)*)/, | |
function (totalMatch) { | |
chunk.endTag = totalMatch; | |
return ""; | |
} | |
); | |
var replaceBlanksInTags = function (useBracket) { | |
var replacement = useBracket ? "> " : ""; | |
if (chunk.startTag) { | |
chunk.startTag = chunk.startTag.replace(/\n((>|\s)*)\n$/, | |
function (totalMatch, markdown) { | |
return "\n" + markdown.replace(/^[ ]{0,3}>?[ \t]*$/gm, replacement) + "\n"; | |
}); | |
} | |
if (chunk.endTag) { | |
chunk.endTag = chunk.endTag.replace(/^\n((>|\s)*)\n/, | |
function (totalMatch, markdown) { | |
return "\n" + markdown.replace(/^[ ]{0,3}>?[ \t]*$/gm, replacement) + "\n"; | |
}); | |
} | |
}; | |
if (/^(?![ ]{0,3}>)/m.test(chunk.selection)) { | |
this.wrap(chunk, SETTINGS.lineLength - 2); | |
chunk.selection = chunk.selection.replace(/^/gm, "> "); | |
replaceBlanksInTags(true); | |
chunk.skipLines(); | |
} else { | |
chunk.selection = chunk.selection.replace(/^[ ]{0,3}> ?/gm, ""); | |
this.unwrap(chunk); | |
replaceBlanksInTags(false); | |
if (!/^(\n|^)[ ]{0,3}>/.test(chunk.selection) && chunk.startTag) { | |
chunk.startTag = chunk.startTag.replace(/\n{0,2}$/, "\n\n"); | |
} | |
if (!/(\n|^)[ ]{0,3}>.*$/.test(chunk.selection) && chunk.endTag) { | |
chunk.endTag = chunk.endTag.replace(/^\n{0,2}/, "\n\n"); | |
} | |
} | |
chunk.selection = this.hooks.postBlockquoteCreation(chunk.selection); | |
if (!/\n/.test(chunk.selection)) { | |
chunk.selection = chunk.selection.replace(/^(> *)/, | |
function (wholeMatch, blanks) { | |
chunk.startTag += blanks; | |
return ""; | |
}); | |
} | |
}; | |
commandProto.doCode = function (chunk, postProcessing) { | |
var hasTextBefore = /\S[ ]*$/.test(chunk.before); | |
var hasTextAfter = /^[ ]*\S/.test(chunk.after); | |
// Use 'four space' markdown if the selection is on its own | |
// line or is multiline. | |
if ((!hasTextAfter && !hasTextBefore) || /\n/.test(chunk.selection)) { | |
chunk.before = chunk.before.replace(/[ ]{4}$/, | |
function (totalMatch) { | |
chunk.selection = totalMatch + chunk.selection; | |
return ""; | |
}); | |
var nLinesBack = 1; | |
var nLinesForward = 1; | |
if (/(\n|^)(\t|[ ]{4,}).*\n$/.test(chunk.before)) { | |
nLinesBack = 0; | |
} | |
if (/^\n(\t|[ ]{4,})/.test(chunk.after)) { | |
nLinesForward = 0; | |
} | |
chunk.skipLines(nLinesBack, nLinesForward); | |
if (!chunk.selection) { | |
chunk.startTag = " "; | |
chunk.selection = this.getString("codeexample"); | |
} | |
else { | |
if (/^[ ]{0,3}\S/m.test(chunk.selection)) { | |
if (/\n/.test(chunk.selection)) | |
chunk.selection = chunk.selection.replace(/^/gm, " "); | |
else // if it's not multiline, do not select the four added spaces; this is more consistent with the doList behavior | |
chunk.before += " "; | |
} | |
else { | |
chunk.selection = chunk.selection.replace(/^(?:[ ]{4}|[ ]{0,3}\t)/gm, ""); | |
} | |
} | |
} | |
else { | |
// Use backticks (`) to delimit the code block. | |
chunk.trimWhitespace(); | |
chunk.findTags(/`/, /`/); | |
if (!chunk.startTag && !chunk.endTag) { | |
chunk.startTag = chunk.endTag = "`"; | |
if (!chunk.selection) { | |
chunk.selection = this.getString("codeexample"); | |
} | |
} | |
else if (chunk.endTag && !chunk.startTag) { | |
chunk.before += chunk.endTag; | |
chunk.endTag = ""; | |
} | |
else { | |
chunk.startTag = chunk.endTag = ""; | |
} | |
} | |
}; | |
commandProto.doList = function (chunk, postProcessing, isNumberedList) { | |
// These are identical except at the very beginning and end. | |
// Should probably use the regex extension function to make this clearer. | |
var previousItemsRegex = /(\n|^)(([ ]{0,3}([*+-]|\d+[.])[ \t]+.*)(\n.+|\n{2,}([*+-].*|\d+[.])[ \t]+.*|\n{2,}[ \t]+\S.*)*)\n*$/; | |
var nextItemsRegex = /^\n*(([ ]{0,3}([*+-]|\d+[.])[ \t]+.*)(\n.+|\n{2,}([*+-].*|\d+[.])[ \t]+.*|\n{2,}[ \t]+\S.*)*)\n*/; | |
// The default bullet is a dash but others are possible. | |
// This has nothing to do with the particular HTML bullet, | |
// it's just a markdown bullet. | |
var bullet = "-"; | |
// The number in a numbered list. | |
var num = 1; | |
// Get the item prefix - e.g. " 1. " for a numbered list, " - " for a bulleted list. | |
var getItemPrefix = function () { | |
var prefix; | |
if (isNumberedList) { | |
prefix = " " + num + ". "; | |
num++; | |
} | |
else { | |
prefix = " " + bullet + " "; | |
} | |
return prefix; | |
}; | |
// Fixes the prefixes of the other list items. | |
var getPrefixedItem = function (itemText) { | |
// The numbering flag is unset when called by autoindent. | |
if (isNumberedList === undefined) { | |
isNumberedList = /^\s*\d/.test(itemText); | |
} | |
// Renumber/bullet the list element. | |
itemText = itemText.replace(/^[ ]{0,3}([*+-]|\d+[.])\s/gm, | |
function (_) { | |
return getItemPrefix(); | |
}); | |
return itemText; | |
}; | |
chunk.findTags(/(\n|^)*[ ]{0,3}([*+-]|\d+[.])\s+/, null); | |
if (chunk.before && !/\n$/.test(chunk.before) && !/^\n/.test(chunk.startTag)) { | |
chunk.before += chunk.startTag; | |
chunk.startTag = ""; | |
} | |
if (chunk.startTag) { | |
var hasDigits = /\d+[.]/.test(chunk.startTag); | |
chunk.startTag = ""; | |
chunk.selection = chunk.selection.replace(/\n[ ]{4}/g, "\n"); | |
this.unwrap(chunk); | |
chunk.skipLines(); | |
if (hasDigits) { | |
// Have to renumber the bullet points if this is a numbered list. | |
chunk.after = chunk.after.replace(nextItemsRegex, getPrefixedItem); | |
} | |
if (isNumberedList == hasDigits) { | |
return; | |
} | |
} | |
var nLinesUp = 1; | |
chunk.before = chunk.before.replace(previousItemsRegex, | |
function (itemText) { | |
if (/^\s*([*+-])/.test(itemText)) { | |
bullet = re.$1; | |
} | |
nLinesUp = /[^\n]\n\n[^\n]/.test(itemText) ? 1 : 0; | |
return getPrefixedItem(itemText); | |
}); | |
if (!chunk.selection) { | |
chunk.selection = this.getString("litem"); | |
} | |
var prefix = getItemPrefix(); | |
var nLinesDown = 1; | |
chunk.after = chunk.after.replace(nextItemsRegex, | |
function (itemText) { | |
nLinesDown = /[^\n]\n\n[^\n]/.test(itemText) ? 1 : 0; | |
return getPrefixedItem(itemText); | |
}); | |
chunk.trimWhitespace(true); | |
chunk.skipLines(nLinesUp, nLinesDown, true); | |
chunk.startTag = prefix; | |
var spaces = prefix.replace(/./g, " "); | |
this.wrap(chunk, SETTINGS.lineLength - spaces.length); | |
chunk.selection = chunk.selection.replace(/\n/g, "\n" + spaces); | |
}; | |
commandProto.doHeading = function (chunk, postProcessing) { | |
// Remove leading/trailing whitespace and reduce internal spaces to single spaces. | |
chunk.selection = chunk.selection.replace(/\s+/g, " "); | |
chunk.selection = chunk.selection.replace(/(^\s+|\s+$)/g, ""); | |
// If we clicked the button with no selected text, we just | |
// make a level 2 hash header around some default text. | |
if (!chunk.selection) { | |
chunk.startTag = "## "; | |
if(chunk.before === "" || !chunk.before.match(/\n$/)){ | |
chunk.startTag = "\n## "; | |
} | |
chunk.selection = this.getString("headingexample"); | |
chunk.endTag = " ##"; | |
if(chunk.after === "" || !chunk.after.match(/^\n/)){ | |
chunk.endTag = " ##\n"; | |
} | |
return; | |
} | |
var headerLevel = 0; // The existing header level of the selected text. | |
// Remove any existing hash heading markdown and save the header level. | |
chunk.findTags(/#+[ ]*/, /[ ]*#+/); | |
if (/#+/.test(chunk.startTag)) { | |
headerLevel = re.lastMatch.length; | |
} | |
chunk.startTag = chunk.endTag = ""; | |
// Try to get the current header level by looking for - and = in the line | |
// below the selection. | |
chunk.findTags(null, /\s?(-+|=+)/); | |
if (/=+/.test(chunk.endTag)) { | |
headerLevel = 1; | |
} | |
if (/-+/.test(chunk.endTag)) { | |
headerLevel = 2; | |
} | |
// Skip to the next line so we can create the header markdown. | |
chunk.startTag = chunk.endTag = ""; | |
chunk.skipLines(1, 1); | |
// We make a level 2 header if there is no current header. | |
// If there is a header level, we substract one from the header level. | |
// If it's already a level 1 header, it's removed. | |
var headerLevelToCreate = headerLevel == 0 ? 2 : headerLevel - 1; | |
if (headerLevelToCreate > 0) { | |
// The button only creates level 1 and 2 underline headers. | |
// Why not have it iterate over hash header levels? Wouldn't that be easier and cleaner? | |
var headerChar = headerLevelToCreate >= 2 ? "-" : "="; | |
var len = chunk.selection.length; | |
if (len > SETTINGS.lineLength) { | |
len = SETTINGS.lineLength; | |
} | |
chunk.endTag = "\n"; | |
while (len--) { | |
chunk.endTag += headerChar; | |
} | |
} | |
}; | |
commandProto.doHorizontalRule = function (chunk, postProcessing) { | |
chunk.startTag = "----------\n"; | |
chunk.selection = ""; | |
chunk.skipLines(2, 1, true); | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment