// console.log('Loading DiscountField...') Spontaneous.FieldTypes.MarkdownField = (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 if ((end - start) > 0) { if (selected.indexOf(this.pre) !== 0) { var sel; for (var 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) { var sel; for (var 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, 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) { var s = m[1]; start -= s.length; selected = s + selected; } if (n) { var 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) { var s = m[1]; start -= s.length; selected = m[1] + selected; } m = /^(.+)/.exec(state.after); if (m) { var 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++) { var 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 (var i = 0, ii = lines.length; i < ii; i++) { var 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); 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); } }); 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; // cursor is either in the [] or in the () // first test for being in [] m = /(\[[^\]]*)$/.exec(state.before); if (m) { n = /^(.*\]\(.+?\))/.exec(state.after); if (n) { start -= m[1].length; selected += m[1]; end += n[1].length; selected += n[1]; } } // now test for being in the () m = /(\[.+?\]\([^\)]*)$/.exec(state.before); if (m) { n = /^(.*?\))/.exec(state.after); if (n) { start -= m[1].length; selected += m[1]; end += n[1].length; selected += n[1]; } } // finally check for being exactly between the two m = /(\[.+?\])$/.exec(state.before); if (m) { n = /^(\(.+?\))/.exec(state.after); if (n) { start -= m[1].length; selected += m[1]; end += n[1].length; selected += n[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.FieldTypes.StringField, { 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.animate({'height':resize_height}); this.expanded = true; } } this.callSuper(); }, on_blur: function() { if (this.expanded) { var input = this.input(); // input.animate({ 'height':input.data('original-height') }); this.expanded = false; } this.callSuper(); }, toolbar: function() { if (!this._toolbar) { this._wrapper = dom.div([dom.id('editor-'+this.css_id()), '.markdown-editor']); // $(dom.div, {'class':'markdown-editor', 'id':'editor-'+this.css_id()}); this._toolbar = dom.div('.md-toolbar'); this.commands = []; var input = this.input(); for (var i = 0, c = this.actions, ii = c.length; i < ii; i++) { var cmd_class = c[i], cmd = new cmd_class(input); this.commands.push(cmd); this._toolbar.append(cmd.button()); } this._wrapper.append(this._toolbar); // input.css('height', ''); } return this._wrapper; }, edit: function() { this.expanded = false; var input = this.input(); input.unbind('select.markdown').unbind('click.markdown').unbind('keyup.markdown'); input.bind('select.markdown', this.on_select.bind(this)) input.bind('click.markdown', this.on_select.bind(this)) input.bind('keyup.markdown', this.on_select.bind(this)) return input; }, close_edit: function() { this._input = null; this._toolbar = null; this.commands = []; this.expanded = false; this.callSuper(); }, // iterates through all the buttons and lets them highlight themselves depending on the // currently selected text on_select: function(event) { var state = TextCommand.get_state(this.input()); $.each(this.commands, function() { this.respond_to_selection(state); }); } }); MarkdownField.extend({ TextCommand: TextCommand, Bold: Bold, Italic: Italic, UL: UL, OL: OL, H1: H1, H2: H2, Link: Link }); return MarkdownField; })(jQuery, Spontaneous);