application/js/field/markdown.js
// console.log('Loading DiscountField...')
Spontaneous.Field.Markdown = (function($, S) {
var dom = S.Dom;
var TextCommand = new JS.Class({
name: '',
pre: '',
post: '',
extend: {
get_state: function(input) {
var start = input[0].selectionStart, end = input[0].selectionEnd, value = input.val(),
before = value.substr(0, start), middle = value.substr(start, (end - start)), after = value.substr(end), state;
state = {
start: start,
end: end,
before: before,
middle: middle,
selection: middle,
after: after
};
// console.log(state)
return state;
}
},
initialize: function(input) {
this.input = input.bind('keydown.markdown', function(event) {
var key = String.fromCharCode(event.keyCode);
if ((event.ctrlKey || event.metaKey) && key === this.key_shortcut()) {
this.execute(event);
return false;
}
}.bind(this));
},
key_shortcut: function() {
return '';
},
execute: function(event) {
this.wrap();
},
wrap: function() {
var input = this.input, s = this.fix_selection(), start = s.start, end = s.end,
before = s.before, middle = s.selection, after = s.after, wrapped;
// if ((end - start) <= 0 ) { return; }
if (this.matches_selection(middle)) {
wrapped = this.remove(middle);
} else {
wrapped = this.surround(middle);
}
input.val(before + wrapped + after);
input[0].selectionStart = start;
input[0].selectionEnd = start + wrapped.length;
},
get_state: function() {
return TextCommand.get_state(this.input);
},
fix_selection_whitespace: function(state) {
var selected = state.selection, m, l;
m = /^( +)/.exec(selected);
if (m) {
l = m[1].length;
state.start += l;
state.selection = selected.substr(l);
}
m = /( +)$/.exec(selected);
if (m) {
l = m[1].length;
state.end -= l;
state.selection = selected.substr(0, selected.length-l);
}
return state;
},
expand_selection: function(state) {
state = this.fix_selection_whitespace(state);
var selected = state.selection, m, start = state.start, end = state.end,
_pre_ = this.pre.replace(/\*/g, '\\*'), _post_ = this.post.replace(/\*/g, '\\*');
m = (new RegExp('(?:^| )('+_pre_+'[^('+_pre_+')]*)$', 'm')).exec(state.before);
if (m) {
start -= m[1].length;
selected = m[1] + selected;
}
m = (new RegExp('^([^('+_post_+')]*?'+_post_+')[^('+_post_+')\w ]*?( |$)', '')).exec(state.after);
if (m) {
end += m[1].length;
selected += m[1];
}
// fix condition where half of the pre/post markers are selected
var sel, i, ii;
if ((end - start) > 0) {
if (selected.indexOf(this.pre) !== 0) {
for (i = 0, ii = this.pre.length; i < ii; i++) {
sel = state.before.substr(-(i+1)) + selected;
if (sel.indexOf(this.pre) === 0) {
start -= (i+1);
selected = sel;
break;
}
}
}
if (selected.substr(-this.post.length) !== this.post) {
for (i = 0, ii = this.post.length; i < ii; i++) {
sel = selected + state.after.substr(0, (i+1));
if (sel.substr(-this.post.length) === this.post) {
end += (i+1);
selected = sel;
break;
}
}
}
} else {
// expand selection to current word if selection is empty
var exclude = '\\s\\b\\.,';
m = (new RegExp('(?:['+exclude+']|^)([^'+exclude+']+)$', '')).exec(state.before);
if (m) {
start -= m[1].length;
selected = m[1] + selected;
}
m = (new RegExp('^([^'+exclude+']*)(?:['+exclude+']|$)', '')).exec(state.after);
if (m) {
end += m[1].length;
selected += m[1];
}
}
return {start: start, end: end, selection:selected};
},
fix_selection: function() {
var state = this.get_state(), change;
if (!this.matches_selection(state.selection)) {
change = this.expand_selection(state);
$.extend(state, change);
state = this.update_state(state);
}
return state;
},
update_state: function(state) {
this.input[0].setSelectionRange(state.start, state.end);
return this.get_state();
},
surround: function(text) {
return this.pre + text + this.post;
},
remove: function(text) {
return text.substr(this.pre.length, text.length - this.pre.length - this.post.length);
},
value: function() {
return this.input.val();
},
button: function() {
if (!this._button) {
// var b = $(dom.a, {'class':this.name.toLowerCase()}).click(function(event) {
var b = dom.a(this.name.toLowerCase()).click(function(event) {
this.execute(event);
return false;
}.bind(this)).text(this.name);
this._button = b;
}
return this._button;
},
respond_to_selection: function(state) {
this.deactivate();
if (this.matches_selection(state.selection) || this.matches_selection(this.expand_selection(state).selection)) {
this.activate();
return true;
} else {
this.deactivate();
return false;
}
},
activate: function() {
this.button().addClass('active');
},
deactivate: function() {
this.button().removeClass('active');
},
matches_removal: function(selection) {
return this.matches_selection(selection);
},
matches_selection: function(selection) {
return (selection.indexOf(this.pre) === 0 && selection.lastIndexOf(this.post) === (selection.length - this.post.length));
}
});
var Bold = new JS.Class(TextCommand, {
name: 'Bold',
pre: '**',
post: '**',
key_shortcut: function() {
return 'B'; // "b"
}
});
var Italic = new JS.Class(TextCommand, {
name: 'Italic',
pre: '_',
post: '_',
key_shortcut: function() {
return 'I';
}
});
var UL = new JS.Class(TextCommand, {
name: 'UL',
pre: '*',
post: '',
br: /\r?\n/,
strip_bullet: /^ *(\d+\.|\*) */,
is_list_entry:/(?:\r?\n)( *\*{1} +.+?)$/,
surround: function(text) {
var lines = text.split(this.br);
for (var i = 0, ii = lines.length; i < ii; i++) {
if (/^\s*$/.test(lines[i])) {
} else {
lines[i] = this.bullet_for(i) + lines[i].replace(this.strip_bullet, '');
}
}
return lines.join('\n');
},
remove: function(text) {
var lines = text.split(this.br);
for (var i = 0, ii = lines.length; i < ii; i++) {
lines[i] = lines[i].replace(this.strip_bullet, '');
}
return lines.join('\n');
},
expand_selection: function(state) {
var selected = (state.selection || ''), m, start = state.start, end = state.end, br = /\r?\n/;
m = this.strip_bullet.exec(selected);
if (!m) {
m = this.is_list_entry.exec(state.before);
if (m) {
start -= m[1].length;
selected = m[1] + selected;
m = /^(.*?)(?:\r?\n)/.exec(state.after);
if (m) {
end += m[1].length;
selected += m[1];
}
}
}
return {selection:selected, start:start, end:end};
},
bullet_for: function(n) {
return '* ';
},
matches_selection: function(selection) {
return /^ *\* +/.test(selection);
}
});
var OL = new JS.Class(UL, {
name: 'OL',
is_list_entry:/(?:\r?\n)( *\d+\..+?)$/,
bullet_for: function(n) {
return (n+1)+'. ';
},
matches_selection: function(selection) {
return /^ *\d+\./.test(selection);
}
});
var H1 = new JS.Class(TextCommand, {
name: 'H1',
pre: '',
post: '=',
scale: 1.0,
key_shortcut: function() {
return '1';
},
surround: function(text) {
// remove existing header (which must be different from this version)
if (this.matches_removal(text)) { text = this.remove(text); }
var line = '', n = Math.floor(this.input.attr('cols')*0.5), newline = /([\r\n]+)$/, newlines = newline.exec(text), undef;
newlines = (!newlines || (newlines === undef) ? '' : newlines[1]);
for (var i = 0; i < n; i++) { line += this.post; }
return text.replace(newline, '') + '\n' + line + newlines;
},
// removes either h1 or h2
remove: function(text) {
var r = new RegExp('[\r\n][=-]+'), s = text.replace(r, '');
return s.replace(/ +$/, '');
},
// matches either h1 or h2
matches_removal: function(selection) {
return (new RegExp('[\r\n][=\\-]+[\r\n ]*$')).exec(selection);
},
// matches only the current header class
matches_selection: function(selection) {
return (new RegExp('[\r\n]?'+this.post+'+[\r\n ]*$', 'm')).exec(selection);
},
expand_selection: function(state) {
var selected = (state.selection || ''), m, s, l, start = state.start, end = state.end, br = /\r?\n/, below = false;
// detect & deal with the cursor being on the line below
// (the one with the -'s or ='s)
// TODO: deal with the case where the cursor is at the start of the =- line
m = /[\r\n]([=-]+)$/.exec(state.before);
n = /^([=-]+)[\r\n]/.exec(state.after);
if (m || n) {
m = /(?:[\n]|^)(.+[\n]+([=-]+))$/.exec(state.before);
if (m) {
s = m[1];
start -= s.length;
selected = s + selected;
}
if (n) {
s = n[1];
end += s.length;
selected += s;
}
below = true;
}
// if we're on the line below then skip all this
if (!below) {
// expand to select current line
m = /(.+)$/.exec(state.before);
if (m) {
s = m[1];
start -= s.length;
selected = m[1] + selected;
}
m = /^(.+)/.exec(state.after);
if (m) {
s = m[1];
end += s.length;
selected += m[1];
}
var lines = selected.split(br), underline = new RegExp('^[=-]+$'), found = false;
for (var i = 0, ii = lines.length; i < ii; i++) {
l = lines[i];
if (underline.test(l)) {
found = true;
break;
}
}
if (!found) {
// expand selection down by one line
lines = state.after.split(br, 2);
for (i = 0, ii = lines.length; i < ii; i++) {
l = lines[i];
if (underline.test(l)) {
end += l.length + i;
selected += l;
break;
}
}
} else {
// make sure that we have the whole of the underline included in the selection
var r = new RegExp('^([=-]+)');
m = r.exec(state.after);
if (m) {
var extra = m[1];
end += extra.length;
selected += m[1];
}
}
}
return {selection:selected, start:start, end:end};
}
});
var H2 = new JS.Class(H1, {
name: 'H2',
post: '-',
scale: 1.2, // hyphens are narrower than equals and narrower than the average char
key_shortcut: function() {
return '2';
}
});
var LinkView = new JS.Class(Spontaneous.PopoverView, {
initialize: function(editor, link_text, url) {
this.editor = editor;
this.link_text = link_text;
this.url = url;
this.callSuper();
},
width: function() {
return 300;
},
title: function() {
return 'Insert Link';
},
// position_from_event: function(event) {
// var t = $(event.currentTarget), o = t.offset();
// o.top += t.outerHeight();
// o.left += t.outerWidth() / 2;
// return o
// },
close_text: function() {
return 'Cancel';
},
// align: 'right',
view: function() {
var __view = this, w = dom.div('.pop-insert-link'), text_input, url_input;
var input = function(label, value, type) {
var l, i = dom[(type || 'input')]({'type':'text'}).keypress(function(event) {
if (event.charCode === 13) {
__view.insert_link_and_close(text_input, url_input); // sick
return false;
}
}).val(value);
l = dom.label().append(dom.span().text(label)).append(i);
return l;
};
text_input = input('Text', this.link_text);
url_input = input('URL', this.url, 'textarea');
url_input.find('textarea').attr('rows', 3);
var cancel = dom.a('.button.cancel').text('Clear').click(function() {
// this.close();
this.insert_link_and_close(text_input, url_input.val(''));
return false;
}.bind(this))
, insert = dom.a('.button').text('OK').click(function() {
this.insert_link_and_close(text_input, url_input);
return false;
}.bind(this));
w.append(dom.p().append(text_input)).append(dom.p().append(url_input));
var buttons = dom.div('.buttons');
url_input = url_input.find(':input');
text_input = text_input.find(':input');
this.text_input = text_input;
this.url_input = url_input;
this.page_browser = new PageSelector(this.url, this);
w.append(this.page_browser.view());
w.append(buttons.append(cancel).append(insert));
this.wrapper = w;
return w;
},
insert_link: function(text, url) {
this.editor.insert_link(text.val(), url.val());
},
insert_link_and_close: function(text, url) {
this.insert_link(text, url);
this.close();
},
cancel: function() {
this.close();
},
after_open: function() {
this.wrapper.find('textarea').select();
},
after_close: function() {
this.editor.dialogue_closed();
},
page_selected: function(page) {
this.url_input.val(page.path);
},
scroll: true
});
var PageSelector = new JS.Class({
initialize: function(location, parent) {
this.parent = parent;
this.location = location;
this.browser = new Spontaneous.PageBrowser(this.location);
this.browser.set_manager(this);
},
view: function() {
var w = dom.div(),
text = dom.span().text('Page Browser'),
inner = dom.div('.link-page-browser');
inner.append(dom.label().append(text)).append(this.browser.view());
w.append(inner);
return w;
},
page_list_loaded: function(view) {
},
page_selected: function(page) {
this.parent.page_selected(page);
},
next_level: function(page) {
this.location = page;
}
});
var Link = new JS.Class(TextCommand, {
name: 'Link',
link_matcher: /^\[([^\]]+)\]\(([^\)]+)\)$/,
execute: function(event) {
var input = this.input, s = this.fix_selection(), start = s.start, end = s.end,
before = s.before, middle = s.middle, after = s.after, wrapped,
m = this.link_matcher.exec(middle), text = middle, url;
if (m) {
text = m[1];
url = m[2];
}
this.open_dialogue(event, text, url);
this.input.focus();
return false;
},
open_dialogue: function(event, text, url) {
if (!this._dialogue) {
this._dialogue = Spontaneous.Popover.open(event, new LinkView(this, text, this.preprocess_url(text, url)));
} else {
this._dialogue.close();
this._dialogue = null;
}
},
expand_selection: function(state) {
state = this.fix_selection_whitespace(state);
var selected = state.selection, m, n, start = state.start, end = state.end;
var linkExp = /(\[[^\]]*?\]\([^\ ]*?\))/g;
var text = this.input.val(), cursor = start, match = 0;
// First look at all the text before and move the cursor past any links.
// This stops us expanding backwards to grab any link found in the text before
// the selection start.
do {
if ((m = linkExp.exec(state.before))) { match = linkExp.lastIndex; }
} while (m && (linkExp.lastIndex < cursor));
// now we've established where the last whole link lives, and can stop ourselves
// including it in the search, we can look backwards
// until we find the start of any link that's around the current selection.
while ((cursor >= match) && (text[cursor] !== '[')) { cursor--; }
if (text[cursor] === '[') {
if ((m = linkExp.exec(text.substr(cursor)))) {
start = cursor;
end = cursor + m[1].length;
selected = m[1];
}
}
return {selection:selected, start:start, end:end};
},
preprocess_url: function(text, url) {
if (!url) {
url = this.postprocess_url(String(text)) || '';
}
return url;
},
postprocess_url: function(url) {
if (url) {
if (/^(https?|mailto|ftp|javscript):/.test(url)) { // URLs staring with a protocol
url = url;
} else if (/^[a-z-]+\.([a-z-]+\.)*[a-z]{2,}(\/[^ ]*)*$/i.exec(url)) { // look for addresses without http:
url = 'http://' + url;
} else if (/^[^ @]+@([a-z-]+\.)+[a-z]{2,}$/i.exec(url)) { // email addresses
url = 'mailto:' + url;
} else if (/^@([a-z0-9_]{1,15})$/i.exec(url)) { // twitter handles
url = 'https://twitter.com/' + url.substring(1);
} else {
// need a flag saying that the string doesn't look like URL because
// this function is used in two places and each one needs to respond
// differently to this condition
return false;
}
}
return url;
},
dialogue_closed: function() {
this._dialogue = null;
this.input.focus();
},
insert_link: function(text, url) {
url = this.postprocess_url(url) || url;
var edit = function(input_text) {
return this.surround_with_link(text, url);
}.bind(this);
this.surround = edit;
this.remove = edit;
this.wrap();
},
surround_with_link: function(text, url) {
if (url === '') {
return text;
} else {
return '[' + text + '](' + url + ')';
}
},
remove_link: function(text) {
// we know that the text must match the regexp for us to arrive here
var m = this.link_matcher.exec(text);
return m[1];
},
matches_selection: function(selection) {
return this.link_matcher.exec(selection);
}
});
var MarkdownField = new JS.Class(Spontaneous.Field.String, {
actions: [Bold, Italic, H1, H2, UL, OL, Link],
generate_input: function() {
var input = dom.textarea(dom.id(this.css_id()), {'name':this.form_name(), 'rows':10, 'cols':90}).val(this.unprocessed_value()), height = this.content.getFieldMetadata(this, 'height');
if (height) {
input.css('height', dom.px(height));
}
return input;
},
on_show: function() {
var self = this, input = self.input();
input.resizable({
handles: 's',
minHeight: 100,
stop: function(event, ui) {
self.content.setFieldMetadata(self, 'height', ui.size.height);
}
});
},
on_focus: function() {
if (!this.expanded) {
var input = this.input(), h = input.innerHeight();
input.data('original-height', h);
var text_height = input[0].scrollHeight, max_height = 500, resize_height = Math.min(text_height, max_height);
// console.log(resize_height, h)
if (Math.abs(resize_height - h) > 20) {
// input.velocity({'height':resize_height});
this.expanded = true;
}
}
this.callSuper();
},
on_blur: function() {
if (this.expanded) {
var input = this.input();
// input.velocity({ 'height':input.data('original-height') });
this.expanded = false;
}
this.callSuper();
},
toolbar: function() {
var self = this;
if (!self._toolbar) {
self._wrapper = dom.div([dom.id('editor-'+self.css_id()), '.markdown-editor']);
self._wrapper.append(self.popupToolbar());
}
return self._wrapper;
},
popupToolbar: function() {
var self = this;
if (!self._popupToolbar) {
var toolbar = dom.div('.md-toolbar');
var arrow = dom.div('.arrow');
toolbar.append(arrow);
self.commands = [];
var input = self.input();
for (var i = 0, c = self.actions, ii = c.length; i < ii; i++) {
var cmd_class = c[i], cmd = new cmd_class(input);
self.commands.push(cmd);
toolbar.append(cmd.button());
}
toolbar.hide();
self._popupToolbar = toolbar;
}
return self._popupToolbar;
},
edit: function() {
var self = this, input = self.input();
self.expanded = false;
// clear previously assigned bindings
input.unbind('select.markdown');
input.bind('select.markdown', self.on_select.bind(self));
// input.bind('click.markdown', self.on_select.bind(self))
// input.bind('keyup.markdown', self.on_select.bind(self))
return input;
},
close_edit: function() {
var self = this;
self._input = null;
self._toolbar = null;
self._popupToolbar = null;
self.commands = [];
self.expanded = false;
self.callSuper();
},
// iterates through all the buttons and lets them highlight themselves depending on the
// currently selected text
on_select: function(event) {
var input = this.input(), toolbar = this.popupToolbar(), state = TextCommand.get_state(input);
$.each(this.commands, function() {
this.respond_to_selection(state);
});
input.showSelectionPopup(toolbar, function(position) {
var tools = {width: toolbar.width(), height: toolbar.height()},
text = { width: input.width(), height: input.height()};
// console.log("position", position, tools, text);
var place = {
left: position.left,
// 5 is half the height of the arrow
// 7 is the padding of the field
top: position.top + 7 - 5 - tools.height
};
var dx = 0;
var arrow = toolbar.find('.arrow'),
arrowLeft = (position.width / 2) - 5;
// if the selection is narrow the arrow can peek over the left
// of the toolbar. This shifts everything over and keeps it neat.
if (position.width < 40) {
place.left = place.left - 15;
arrowLeft += 15;
}
if ((place.left + tools.width) > (text.width)) {
dx = ((place.left + tools.width) - (text.width + 20));
place.left = place.left - dx;
arrowLeft += dx;
}
arrowLeft = Math.min(toolbar.width() - 30, arrowLeft);
arrow.css('left', dom.px(arrowLeft));
return place;
});
}
});
MarkdownField.extend({
TextCommand: TextCommand,
Bold: Bold,
Italic: Italic,
UL: UL,
OL: OL,
H1: H1,
H2: H2,
Link: Link
});
return MarkdownField;
})(jQuery, Spontaneous);