vendor/assets/javascripts/epiceditor.js.erb in epic-editor-rails-0.2.0 vs vendor/assets/javascripts/epiceditor.js.erb in epic-editor-rails-0.2.2

- old
+ new

@@ -49,11 +49,11 @@ } return y; } /** - * Saves the current style state for the styles requested, then applys styles + * Saves the current style state for the styles requested, then applies styles * to overwrite the existing one. The old styles are returned as an object so * you can pass it back in when you want to revert back to the old style * @param {object} el The element to get the styles of * @param {string} type Can be "save" or "apply". apply will just apply styles you give it. Save will write styles * @param {object} styles Key/value style/property pairs @@ -99,11 +99,11 @@ * @returns {int} */ function _outerHeight(el) { var b = parseInt(_getStyle(el, 'border-top-width'), 10) + parseInt(_getStyle(el, 'border-bottom-width'), 10) , p = parseInt(_getStyle(el, 'padding-top'), 10) + parseInt(_getStyle(el, 'padding-bottom'), 10) - , w = el.offsetHeight + , w = parseInt(_getStyle(el, 'height'), 10) , t; // For IE in case no border is set and it defaults to "medium" if (isNaN(b)) { b = 0; } t = b + p + w; return t; @@ -163,32 +163,43 @@ } return theText; } function _setText(el, content) { - // If you want to know why we check for typeof string, see comment - // in the _getText function - if (typeof document.body.innerText == 'string') { - content = content.replace(/ /g, '\u00a0'); - el.innerText = content; - } - else { - // Don't convert lt/gt characters as HTML when viewing the editor window - // TODO: Write a test to catch regressions for this - content = content.replace(/</g, '&lt;'); - content = content.replace(/>/g, '&gt;'); - content = content.replace(/\n/g, '<br>'); - // Make sure to look for TWO spaces and replace with a space and &nbsp; - // If you find and replace every space with a &nbsp; text will not wrap. - // Hence the name (Non-Breaking-SPace). - content = content.replace(/\s\s/g, ' &nbsp;') - el.innerHTML = content; - } + // Don't convert lt/gt characters as HTML when viewing the editor window + // TODO: Write a test to catch regressions for this + content = content.replace(/</g, '&lt;'); + content = content.replace(/>/g, '&gt;'); + content = content.replace(/\n/g, '<br>'); + + // Make sure to there aren't two spaces in a row (replace one with &nbsp;) + // If you find and replace every space with a &nbsp; text will not wrap. + // Hence the name (Non-Breaking-SPace). + // TODO: Probably need to test this somehow... + content = content.replace(/<br>\s/g, '<br>&nbsp;') + content = content.replace(/\s\s\s/g, '&nbsp; &nbsp;') + content = content.replace(/\s\s/g, '&nbsp; ') + content = content.replace(/^ /, '&nbsp;') + + el.innerHTML = content; return true; } /** + * Converts the 'raw' format of a file's contents into plaintext + * @param {string} content Contents of the file + * @returns {string} the sanitized content + */ + function _sanitizeRawContent(content) { + // Get this, 2 spaces in a content editable actually converts to: + // 0020 00a0, meaning, "space no-break space". So, manually convert + // no-break spaces to spaces again before handing to marked. + // Also, WebKit converts no-break to unicode equivalent and FF HTML. + return content.replace(/\u00a0/g, ' ').replace(/&nbsp;/g, ' '); + } + + /** * Will return the version number if the browser is IE. If not will return -1 * TRY NEVER TO USE THIS AND USE FEATURE DETECTION IF POSSIBLE * @returns {Number} -1 if false or the version number if true */ function _isIE() { @@ -215,10 +226,20 @@ var n = window.navigator; return n.userAgent.indexOf('Safari') > -1 && n.userAgent.indexOf('Chrome') == -1; } /** + * Same as the isIE(), but simply returns a boolean + * THIS IS TERRIBLE ONLY USE IF ABSOLUTELY NEEDED + * @returns {Boolean} true if Safari + */ + function _isFirefox() { + var n = window.navigator; + return n.userAgent.indexOf('Firefox') > -1 && n.userAgent.indexOf('Seamonkey') == -1; + } + + /** * Determines if supplied value is a function * @param {object} object to determine type */ function _isFunction(functionToCheck) { var getType = {}; @@ -303,11 +324,12 @@ var self = this , opts = options || {} , _defaultFileSchema , _defaultFile , defaults = { container: 'epiceditor' - , basePath: 'epiceditor' + , basePath: '' + , textarea: undefined , clientSideStorage: true , localStorageName: 'epiceditor' , useNativeFullscreen: true , file: { name: null , defaultContent: '' @@ -320,23 +342,64 @@ , focusOnLoad: false , shortcut: { modifier: 18 // alt keycode , fullscreen: 70 // f keycode , preview: 80 // p keycode } + , string: { togglePreview: 'Toggle Preview Mode' + , toggleEdit: 'Toggle Edit Mode' + , toggleFullscreen: 'Enter Fullscreen' + } , parser: typeof marked == 'function' ? marked : null + , autogrow: false + , button: { fullscreen: true + , preview: true + , bar: "auto" + } } - , defaultStorage; + , defaultStorage + , autogrowDefaults = { minHeight: 80 + , maxHeight: false + , scroll: true + }; self.settings = _mergeObjs(true, defaults, opts); + + var buttons = self.settings.button; + self._fullscreenEnabled = typeof(buttons) === 'object' ? typeof buttons.fullscreen === 'undefined' || buttons.fullscreen : buttons === true; + self._editEnabled = typeof(buttons) === 'object' ? typeof buttons.edit === 'undefined' || buttons.edit : buttons === true; + self._previewEnabled = typeof(buttons) === 'object' ? typeof buttons.preview === 'undefined' || buttons.preview : buttons === true; if (!(typeof self.settings.parser == 'function' && typeof self.settings.parser('TEST') == 'string')) { self.settings.parser = function (str) { return str; } } + if (self.settings.autogrow) { + if (self.settings.autogrow === true) { + self.settings.autogrow = autogrowDefaults; + } + else { + self.settings.autogrow = _mergeObjs(true, autogrowDefaults, self.settings.autogrow); + } + self._oldHeight = -1; + } + // If you put an absolute link as the path of any of the themes ignore the basePath + // preview theme + if (!self.settings.theme.preview.match(/^https?:\/\//)) { + self.settings.theme.preview = self.settings.basePath + self.settings.theme.preview; + } + // editor theme + if (!self.settings.theme.editor.match(/^https?:\/\//)) { + self.settings.theme.editor = self.settings.basePath + self.settings.theme.editor; + } + // base theme + if (!self.settings.theme.base.match(/^https?:\/\//)) { + self.settings.theme.base = self.settings.basePath + self.settings.theme.base; + } + // Grab the container element and save it to self.element // if it's a string assume it's an ID and if it's an object // assume it's a DOM element if (typeof self.settings.container == 'string') { self.element = document.getElementById(self.settings.container); @@ -364,10 +427,18 @@ self.settings.file.name = '__epiceditor-untitled-' + EpicEditor._data.unnamedEditors.length; } } } + if (self.settings.button.bar === "show") { + self.settings.button.bar = true; + } + + if (self.settings.button.bar === "hide") { + self.settings.button.bar = false; + } + // Protect the id and overwrite if passed in as an option // TODO: Put underscrore to denote that this is private self._instanceId = 'epiceditor-' + Math.round(Math.random() * 100000); self._storage = {}; self._canSave = true; @@ -382,11 +453,10 @@ } if (localStorage && self.settings.clientSideStorage) { this._storage = localStorage; if (this._storage[self.settings.localStorageName] && self.getFiles(self.settings.file.name) === undefined) { - _defaultFile = self.getFiles(self.settings.file.name); _defaultFile = self._defaultFileSchema(); _defaultFile.content = self.settings.file.defaultContent; } } @@ -395,10 +465,15 @@ defaultStorage[self.settings.file.name] = self._defaultFileSchema(); defaultStorage = JSON.stringify(defaultStorage); this._storage[self.settings.localStorageName] = defaultStorage; } + // A string to prepend files with to save draft versions of files + // and reset all preview drafts on each load! + self._previewDraftLocation = '__draft-'; + self._storage[self._previewDraftLocation + self.settings.localStorageName] = self._storage[self.settings.localStorageName]; + // This needs to replace the use of classes to check the state of EE self._eeState = { fullscreen: false , preview: false , edit: false @@ -435,24 +510,36 @@ , keypressTimer , mousePos = { y: -1, x: -1 } , _elementStates , _isInEdit , nativeFs = false + , nativeFsWebkit = false + , nativeFsMoz = false + , nativeFsW3C = false , fsElement , isMod = false , isCtrl = false , eventableIframes - , i; // i is reused for loops + , i // i is reused for loops + , boundAutogrow; + // Startup is a way to check if this EpicEditor is starting up. Useful for + // checking and doing certain things before EpicEditor emits a load event. + self._eeState.startup = true; + if (self.settings.useNativeFullscreen) { - nativeFs = document.body.webkitRequestFullScreen ? true : false + nativeFsWebkit = document.body.webkitRequestFullScreen ? true : false; + nativeFsMoz = document.body.mozRequestFullScreen ? true : false; + nativeFsW3C = document.body.requestFullscreen ? true : false; + nativeFs = nativeFsWebkit || nativeFsMoz || nativeFsW3C; } // Fucking Safari's native fullscreen works terribly // REMOVE THIS IF SAFARI 7 WORKS BETTER if (_isSafari()) { nativeFs = false; + nativeFsWebkit = false; } // It opens edit mode by default (for now); if (!self.is('edit') && !self.is('preview')) { self._eeState.edit = true; @@ -466,18 +553,19 @@ // This is wrapping iframe element. It contains the other two iframes and the utilbar chrome: '<div id="epiceditor-wrapper" class="epiceditor-edit-mode">' + '<iframe frameborder="0" id="epiceditor-editor-frame"></iframe>' + '<iframe frameborder="0" id="epiceditor-previewer-frame"></iframe>' + '<div id="epiceditor-utilbar">' + - '<img width="30" src="<%= asset_path("preview.png") %>" title="Toggle Preview Mode" class="epiceditor-toggle-btn epiceditor-toggle-preview-btn"> ' + - '<img width="30" src="<%= asset_path("edit.png") %>" title="Toggle Edit Mode" class="epiceditor-toggle-btn epiceditor-toggle-edit-btn"> ' + - '<img width="30" src="<%= asset_path("fullscreen.png") %>" title="Enter Fullscreen" class="epiceditor-fullscreen-btn">' + + (self._previewEnabled ? '<button title="' + this.settings.string.togglePreview + '" class="epiceditor-toggle-btn epiceditor-toggle-preview-btn"></button> ' : '') + + (self._editEnabled ? '<button title="' + this.settings.string.toggleEdit + '" class="epiceditor-toggle-btn epiceditor-toggle-edit-btn"></button> ' : '') + + (self._fullscreenEnabled ? '<button title="' + this.settings.string.toggleFullscreen + '" class="epiceditor-fullscreen-btn"></button>' : '') + '</div>' + '</div>' // The previewer is just an empty box for the generated HTML to go into , previewer: '<div id="epiceditor-preview"></div>' + , editor: '<!doctype HTML>' }; // Write an iframe and then select it for the editor self.element.innerHTML = '<iframe scrolling="no" frameborder="0" id= "' + self._instanceId + '"></iframe>'; @@ -504,11 +592,11 @@ // Setup the editor iframe self.editorIframeDocument = _getIframeInnards(self.editorIframe); self.editorIframeDocument.open(); // Need something for... you guessed it, Firefox - self.editorIframeDocument.write(''); + self.editorIframeDocument.write(_HtmlTemplates.editor); self.editorIframeDocument.close(); // Setup the previewer iframe self.previewerIframeDocument = _getIframeInnards(self.previewerIframe); self.previewerIframeDocument.open(); @@ -533,22 +621,29 @@ _insertCSSLink(self.settings.theme.preview, self.previewerIframeDocument, 'theme'); // Add a relative style to the overall wrapper to keep CSS relative to the editor self.iframe.getElementById('epiceditor-wrapper').style.position = 'relative'; + // Set the position to relative so we hide them with left: -999999px + self.editorIframe.style.position = 'absolute'; + self.previewerIframe.style.position = 'absolute'; + // Now grab the editor and previewer for later use self.editor = self.editorIframeDocument.body; self.previewer = self.previewerIframeDocument.getElementById('epiceditor-preview'); self.editor.contentEditable = true; // Firefox's <body> gets all fucked up so, to be sure, we need to hardcode it self.iframe.body.style.height = this.element.offsetHeight + 'px'; // Should actually check what mode it's in! - this.previewerIframe.style.display = 'none'; + self.previewerIframe.style.left = '-999999px'; + // Keep long lines from being longer than the editor + this.editorIframeDocument.body.style.wordWrap = 'break-word'; + // FIXME figure out why it needs +2 px if (_isIE() > -1) { this.previewer.style.height = parseInt(_getStyle(this.previewer, 'height'), 10) + 2; } @@ -558,27 +653,57 @@ if (self.settings.focusOnLoad) { // We need to wait until all three iframes are done loading by waiting until the parent // iframe's ready state == complete, then we can focus on the contenteditable self.iframe.addEventListener('readystatechange', function () { if (self.iframe.readyState == 'complete') { - self.editorIframeDocument.body.focus(); + self.focus(); } }); } + // Because IE scrolls the whole window to hash links, we need our own + // method of scrolling the iframe to an ID from clicking a hash + self.previewerIframeDocument.addEventListener('click', function (e) { + var el = e.target + , body = self.previewerIframeDocument.body; + if (el.nodeName == 'A') { + // Make sure the link is a hash and the link is local to the iframe + if (el.hash && el.hostname == window.location.hostname) { + // Prevent the whole window from scrolling + e.preventDefault(); + // Prevent opening a new window + el.target = '_self'; + // Scroll to the matching element, if an element exists + if (body.querySelector(el.hash)) { + body.scrollTop = body.querySelector(el.hash).offsetTop; + } + } + } + }); + utilBtns = self.iframe.getElementById('epiceditor-utilbar'); + // TODO: Move into fullscreen setup function (_setupFullscreen) _elementStates = {} self._goFullscreen = function (el) { - + this._fixScrollbars('auto'); + if (self.is('fullscreen')) { self._exitFullscreen(el); return; } if (nativeFs) { - el.webkitRequestFullScreen(); + if (nativeFsWebkit) { + el.webkitRequestFullScreen(); + } + else if (nativeFsMoz) { + el.mozRequestFullScreen(); + } + else if (nativeFsW3C) { + el.requestFullscreen(); + } } _isInEdit = self.is('edit'); // Set the state of EE in fullscreen @@ -606,20 +731,24 @@ , 'height': windowOuterHeight + 'px' , 'float': 'left' // Most browsers , 'cssFloat': 'left' // FF , 'styleFloat': 'left' // Older IEs , 'display': 'block' + , 'position': 'static' + , 'left': '' }); // the previewer _elementStates.previewerIframe = _saveStyleState(self.previewerIframe, 'save', { 'width': windowOuterWidth / 2 + 'px' , 'height': windowOuterHeight + 'px' , 'float': 'right' // Most browsers , 'cssFloat': 'right' // FF , 'styleFloat': 'right' // Older IEs , 'display': 'block' + , 'position': 'static' + , 'left': '' }); // Setup the containing element CSS for fullscreen _elementStates.element = _saveStyleState(self.element, 'save', { 'position': 'fixed' @@ -648,16 +777,18 @@ document.body.style.overflow = 'hidden'; } self.preview(); - self.editorIframeDocument.body.focus(); + self.focus(); self.emit('fullscreenenter'); }; self._exitFullscreen = function (el) { + this._fixScrollbars(); + _saveStyleState(self.element, 'apply', _elementStates.element); _saveStyleState(self.iframeElement, 'apply', _elementStates.iframeElement); _saveStyleState(self.editorIframe, 'apply', _elementStates.editorIframe); _saveStyleState(self.previewerIframe, 'apply', _elementStates.previewerIframe); @@ -667,19 +798,30 @@ self.element.style.width = self._eeState.reflowWidth ? self._eeState.reflowWidth : ''; self.element.style.height = self._eeState.reflowHeight ? self._eeState.reflowHeight : ''; utilBtns.style.visibility = 'visible'; + // Put the editor back in the right state + // TODO: This is ugly... how do we make this nicer? + // setting fullscreen to false here prevents the + // native fs callback from calling this function again + self._eeState.fullscreen = false; + if (!nativeFs) { document.body.style.overflow = 'auto'; } else { - document.webkitCancelFullScreen(); + if (nativeFsWebkit) { + document.webkitCancelFullScreen(); + } + else if (nativeFsMoz) { + document.mozCancelFullScreen(); + } + else if (nativeFsW3C) { + document.exitFullscreen(); + } } - // Put the editor back in the right state - // TODO: This is ugly... how do we make this nicer? - self._eeState.fullscreen = false; if (_isInEdit) { self.edit(); } else { @@ -718,30 +860,50 @@ self._goFullscreen(fsElement); } }); // Sets up the NATIVE fullscreen editor/previewer for WebKit - if (document.body.webkitRequestFullScreen) { - fsElement.addEventListener('webkitfullscreenchange', function () { - if (!document.webkitIsFullScreen) { + if (nativeFsWebkit) { + document.addEventListener('webkitfullscreenchange', function () { + if (!document.webkitIsFullScreen && self._eeState.fullscreen) { self._exitFullscreen(fsElement); } }, false); } + else if (nativeFsMoz) { + document.addEventListener('mozfullscreenchange', function () { + if (!document.mozFullScreen && self._eeState.fullscreen) { + self._exitFullscreen(fsElement); + } + }, false); + } + else if (nativeFsW3C) { + document.addEventListener('fullscreenchange', function () { + if (document.fullscreenElement == null && self._eeState.fullscreen) { + self._exitFullscreen(fsElement); + } + }, false); + } + // TODO: Move utilBar stuff into a utilBar setup function (_setupUtilBar) utilBar = self.iframe.getElementById('epiceditor-utilbar'); // Hide it at first until they move their mouse - utilBar.style.display = 'none'; + if (self.settings.button.bar !== true) { + utilBar.style.display = 'none'; + } utilBar.addEventListener('mouseover', function () { if (utilBarTimer) { clearTimeout(utilBarTimer); } }); function utilBarHandler(e) { + if (self.settings.button.bar !== "auto") { + return; + } // Here we check if the mouse has moves more than 5px in any direction before triggering the mousemove code // we do this for 2 reasons: // 1. On Mac OS X lion when you scroll and it does the iOS like "jump" when it hits the top/bottom of the page itll fire off // a mousemove of a few pixels depending on how hard you scroll // 2. We give a slight buffer to the user in case he barely touches his touchpad or mouse and not trigger the UI @@ -766,19 +928,19 @@ if (e.keyCode == 17) { isCtrl = true } // check for ctrl/cmnd press, in order to catch ctrl/cmnd + s // Check for alt+p and make sure were not in fullscreen - default shortcut to switch to preview if (isMod === true && e.keyCode == self.settings.shortcut.preview && !self.is('fullscreen')) { e.preventDefault(); - if (self.is('edit')) { + if (self.is('edit') && self._previewEnabled) { self.preview(); } - else { + else if (self._editEnabled) { self.edit(); } } // Check for alt+f - default shortcut to make editor fullscreen - if (isMod === true && e.keyCode == self.settings.shortcut.fullscreen) { + if (isMod === true && e.keyCode == self.settings.shortcut.fullscreen && self._fullscreenEnabled) { e.preventDefault(); self._goFullscreen(fsElement); } // Set the modifier key to false once *any* key combo is completed @@ -810,10 +972,33 @@ function shortcutUpHandler(e) { if (e.keyCode == self.settings.shortcut.modifier) { isMod = false } if (e.keyCode == 17) { isCtrl = false } } + function pasteHandler(e) { + var content; + if (e.clipboardData) { + //FF 22, Webkit, "standards" + e.preventDefault(); + content = e.clipboardData.getData("text/plain"); + self.editorIframeDocument.execCommand("insertText", false, content); + } + else if (window.clipboardData) { + //IE, "nasty" + e.preventDefault(); + content = window.clipboardData.getData("Text"); + content = content.replace(/</g, '&lt;'); + content = content.replace(/>/g, '&gt;'); + content = content.replace(/\n/g, '<br>'); + content = content.replace(/\r/g, ''); //fuck you, ie! + content = content.replace(/<br>\s/g, '<br>&nbsp;') + content = content.replace(/\s\s\s/g, '&nbsp; &nbsp;') + content = content.replace(/\s\s/g, '&nbsp; ') + self.editorIframeDocument.selection.createRange().pasteHTML(content); + } + } + // Hide and show the util bar based on mouse movements eventableIframes = [self.previewerIframeDocument, self.editorIframeDocument]; for (i = 0; i < eventableIframes.length; i++) { eventableIframes[i].addEventListener('mousemove', function (e) { @@ -826,26 +1011,36 @@ shortcutUpHandler(e); }); eventableIframes[i].addEventListener('keydown', function (e) { shortcutHandler(e); }); + eventableIframes[i].addEventListener('paste', function (e) { + pasteHandler(e); + }); } // Save the document every 100ms by default + // TODO: Move into autosave setup function (_setupAutoSave) if (self.settings.file.autoSave) { - self.saveInterval = window.setInterval(function () { + self._saveIntervalTimer = window.setInterval(function () { if (!self._canSave) { return; } - self.save(); + self.save(false, true); }, self.settings.file.autoSave); } + // Update a textarea automatically if a textarea is given so you don't need + // AJAX to submit a form and instead fall back to normal form behavior + if (self.settings.textarea) { + self._setupTextareaSync(); + } + window.addEventListener('resize', function () { // If NOT webkit, and in fullscreen, we need to account for browser resizing // we don't care about webkit because you can't resize in webkit's fullscreen - if (!self.iframe.webkitRequestFullScreen && self.is('fullscreen')) { + if (self.is('fullscreen')) { _applyStyles(self.iframeElement, { 'width': window.outerWidth + 'px' , 'height': window.innerHeight + 'px' }); @@ -879,17 +1074,123 @@ else { self.edit(); } self.iframe.close(); + self._eeState.startup = false; + + if (self.settings.autogrow) { + self._fixScrollbars(); + + boundAutogrow = function () { + setTimeout(function () { + self._autogrow(); + }, 1); + }; + + //for if autosave is disabled or very slow + ['keydown', 'keyup', 'paste', 'cut'].forEach(function (ev) { + self.getElement('editor').addEventListener(ev, boundAutogrow); + }); + + self.on('__update', boundAutogrow); + self.on('edit', function () { + setTimeout(boundAutogrow, 50) + }); + self.on('preview', function () { + setTimeout(boundAutogrow, 50) + }); + + //for browsers that have rendering delays + setTimeout(boundAutogrow, 50); + boundAutogrow(); + } + // The callback and call are the same thing, but different ways to access them callback.call(this); this.emit('load'); return this; } + EpicEditor.prototype._setupTextareaSync = function () { + var self = this + , textareaFileName = self.settings.file.name + , _syncTextarea; + + // Even if autoSave is false, we want to make sure to keep the textarea synced + // with the editor's content. One bad thing about this tho is that we're + // creating two timers now in some configurations. We keep the textarea synced + // by saving and opening the textarea content from the draft file storage. + self._textareaSaveTimer = window.setInterval(function () { + if (!self._canSave) { + return; + } + self.save(true); + }, 100); + + _syncTextarea = function () { + // TODO: Figure out root cause for having to do this ||. + // This only happens for draft files. Probably has something to do with + // the fact draft files haven't been saved by the time this is called. + // TODO: Add test for this case. + self._textareaElement.value = self.exportFile(textareaFileName, 'text', true) || self.settings.file.defaultContent; + } + + if (typeof self.settings.textarea == 'string') { + self._textareaElement = document.getElementById(self.settings.textarea); + } + else if (typeof self.settings.textarea == 'object') { + self._textareaElement = self.settings.textarea; + } + + // On page load, if there's content in the textarea that means one of two + // different things: + // + // 1. The editor didn't load and the user was writing in the textarea and + // now he refreshed the page or the JS loaded and the textarea now has + // content. If this is the case the user probably expects his content is + // moved into the editor and not lose what he typed. + // + // 2. The developer put content in the textarea from some server side + // code. In this case, the textarea will take precedence. + // + // If the developer wants drafts to be recoverable they should check if + // the local file in localStorage's modified date is newer than the server. + if (self._textareaElement.value !== '') { + self.importFile(textareaFileName, self._textareaElement.value); + + // manually save draft after import so there is no delay between the + // import and exporting in _syncTextarea. Without this, _syncTextarea + // will pull the saved data from localStorage which will be <=100ms old. + self.save(true); + } + + // Update the textarea on load and pull from drafts + _syncTextarea(); + + // Make sure to keep it updated + self.on('__update', _syncTextarea); + } + /** + * Will NOT focus the editor if the editor is still starting up AND + * focusOnLoad is set to false. This allows you to place this in code that + * gets fired during .load() without worrying about it overriding the user's + * option. For example use cases see preview() and edit(). + * @returns {undefined} + */ + + // Prevent focus when the user sets focusOnLoad to false by checking if the + // editor is starting up AND if focusOnLoad is true + EpicEditor.prototype._focusExceptOnLoad = function () { + var self = this; + if ((self._eeState.startup && self.settings.focusOnLoad) || !self._eeState.startup) { + self.focus(); + } + } + + /** * Will remove the editor, but not offline files * @returns {object} EpicEditor will be returned */ EpicEditor.prototype.unload = function (callback) { @@ -903,103 +1204,141 @@ editor.parentNode.removeChild(editor); self._eeState.loaded = false; self._eeState.unloaded = true; callback = callback || function () {}; - - if (self.saveInterval) { - window.clearInterval(self.saveInterval); + + if (self.settings.textarea) { + self._textareaElement.value = ""; + self.removeListener('__update'); } - + + if (self._saveIntervalTimer) { + window.clearInterval(self._saveIntervalTimer); + } + if (self._textareaSaveTimer) { + window.clearInterval(self._textareaSaveTimer); + } + callback.call(this); self.emit('unload'); return self; } /** * reflow allows you to dynamically re-fit the editor in the parent without * having to unload and then reload the editor again. * - * @param {string} kind Can either be 'width' or 'height' or null - * if null, both the height and width will be resized + * reflow will also emit a `reflow` event and will return the new dimensions. + * If it's called without params it'll return the new width and height and if + * it's called with just width or just height it'll just return the width or + * height. It's returned as an object like: { width: '100px', height: '1px' } * + * @param {string|null} kind Can either be 'width' or 'height' or null + * if null, both the height and width will be resized + * @param {function} callback A function to fire after the reflow is finished. + * Will return the width / height in an obj as the first param of the callback. * @returns {object} EpicEditor will be returned */ - EpicEditor.prototype.reflow = function (kind) { + EpicEditor.prototype.reflow = function (kind, callback) { var self = this , widthDiff = _outerWidth(self.element) - self.element.offsetWidth , heightDiff = _outerHeight(self.element) - self.element.offsetHeight , elements = [self.iframeElement, self.editorIframe, self.previewerIframe] + , eventData = {} , newWidth , newHeight; + if (typeof kind == 'function') { + callback = kind; + kind = null; + } + if (!callback) { + callback = function () {}; + } + for (var x = 0; x < elements.length; x++) { if (!kind || kind == 'width') { newWidth = self.element.offsetWidth - widthDiff + 'px'; elements[x].style.width = newWidth; self._eeState.reflowWidth = newWidth; + eventData.width = newWidth; } if (!kind || kind == 'height') { newHeight = self.element.offsetHeight - heightDiff + 'px'; elements[x].style.height = newHeight; self._eeState.reflowHeight = newHeight + eventData.height = newHeight; } } + + self.emit('reflow', eventData); + callback.call(this, eventData); return self; } /** * Will take the markdown and generate a preview view based on the theme - * @param {string} theme The path to the theme you want to preview in * @returns {object} EpicEditor will be returned */ - EpicEditor.prototype.preview = function (theme) { + EpicEditor.prototype.preview = function () { var self = this , x + , theme = self.settings.theme.preview , anchors; - theme = theme || self.settings.basePath + self.settings.theme.preview; - _replaceClass(self.getElement('wrapper'), 'epiceditor-edit-mode', 'epiceditor-preview-mode'); // Check if no CSS theme link exists if (!self.previewerIframeDocument.getElementById('theme')) { _insertCSSLink(theme, self.previewerIframeDocument, 'theme'); } else if (self.previewerIframeDocument.getElementById('theme').name !== theme) { self.previewerIframeDocument.getElementById('theme').href = theme; } - // Add the generated HTML into the previewer - self.previewer.innerHTML = self.exportFile(null, 'html'); + // Save a preview draft since it might not be saved to the real file yet + self.save(true); - // Because we have a <base> tag so all links open in a new window we - // need to prevent hash links from opening in a new window - anchors = self.previewer.getElementsByTagName('a'); - for (x in anchors) { - // If the link is a hash AND the links hostname is the same as the - // current window's hostname (same page) then set the target to self - if (anchors[x].hash && anchors[x].hostname == window.location.hostname) { - anchors[x].target = '_self'; - } - } + // Add the generated draft HTML into the previewer + self.previewer.innerHTML = self.exportFile(null, 'html', true); // Hide the editor and display the previewer if (!self.is('fullscreen')) { - self.editorIframe.style.display = 'none'; - self.previewerIframe.style.display = 'block'; + self.editorIframe.style.left = '-999999px'; + self.previewerIframe.style.left = ''; self._eeState.preview = true; self._eeState.edit = false; - self.previewerIframe.focus(); + self._focusExceptOnLoad(); } - + self.emit('preview'); return self; } /** + * Helper to focus on the editor iframe. Will figure out which iframe to + * focus on based on which one is active and will handle the cross browser + * issues with focusing on the iframe vs the document body. + * @returns {object} EpicEditor will be returned + */ + EpicEditor.prototype.focus = function (pageload) { + var self = this + , isPreview = self.is('preview') + , focusElement = isPreview ? self.previewerIframeDocument.body + : self.editorIframeDocument.body; + + if (_isFirefox() && isPreview) { + focusElement = self.previewerIframe; + } + + focusElement.focus(); + return this; + } + + /** * Puts the editor into fullscreen mode * @returns {object} EpicEditor will be returned */ EpicEditor.prototype.enterFullscreen = function () { if (this.is('fullscreen')) { return this; } @@ -1024,13 +1363,13 @@ EpicEditor.prototype.edit = function () { var self = this; _replaceClass(self.getElement('wrapper'), 'epiceditor-preview-mode', 'epiceditor-edit-mode'); self._eeState.preview = false; self._eeState.edit = true; - self.editorIframe.style.display = 'block'; - self.previewerIframe.style.display = 'none'; - self.editorIframe.focus(); + self.editorIframe.style.left = ''; + self.previewerIframe.style.left = '-999999px'; + self._focusExceptOnLoad(); self.emit('edit'); return this; } /** @@ -1075,10 +1414,14 @@ return self._eeState.preview case 'edit': return self._eeState.edit; case 'fullscreen': return self._eeState.fullscreen; + // TODO: This "works", but the tests are saying otherwise. Come back to this + // and figure out how to fix it. + // case 'focused': + // return document.activeElement == self.iframeElement; default: return false; } } @@ -1092,13 +1435,13 @@ , defaultContent = self.settings.file.defaultContent , fileObj; name = name || self.settings.file.name; self.settings.file.name = name; if (this._storage[self.settings.localStorageName]) { - fileObj = self.getFiles(); - if (fileObj[name] !== undefined) { - _setText(self.editor, fileObj[name].content); + fileObj = self.exportFile(name); + if (fileObj !== undefined) { + _setText(self.editor, fileObj); self.emit('read'); } else { _setText(self.editor, defaultContent); self.save(); // ensure a save @@ -1112,44 +1455,66 @@ /** * Saves content for offline use * @returns {object} EpicEditor will be returned */ - EpicEditor.prototype.save = function () { + EpicEditor.prototype.save = function (_isPreviewDraft, _isAuto) { var self = this , storage , isUpdate = false , file = self.settings.file.name + , previewDraftName = '' + , data = this._storage[previewDraftName + self.settings.localStorageName] , content = _getText(this.editor); + if (_isPreviewDraft) { + previewDraftName = self._previewDraftLocation; + } + // This could have been false but since we're manually saving // we know it's save to start autoSaving again this._canSave = true; - storage = JSON.parse(this._storage[self.settings.localStorageName]); + // Guard against storage being wiped out without EpicEditor knowing + // TODO: Emit saving error - storage seems to have been wiped + if (data) { + storage = JSON.parse(this._storage[previewDraftName + self.settings.localStorageName]); - // If the file doesn't exist we need to create it - if (storage[file] === undefined) { - storage[file] = self._defaultFileSchema(); - } + // If the file doesn't exist we need to create it + if (storage[file] === undefined) { + storage[file] = self._defaultFileSchema(); + } - // If it does, we need to check if the content is different and - // if it is, send the update event and update the timestamp - else if (content !== storage[file].content) { - storage[file].modified = new Date(); - isUpdate = true; - } + // If it does, we need to check if the content is different and + // if it is, send the update event and update the timestamp + else if (content !== storage[file].content) { + storage[file].modified = new Date(); + isUpdate = true; + } + //don't bother autosaving if the content hasn't actually changed + else if (_isAuto) { + return; + } - storage[file].content = content; - this._storage[self.settings.localStorageName] = JSON.stringify(storage); + storage[file].content = content; + this._storage[previewDraftName + self.settings.localStorageName] = JSON.stringify(storage); - // After the content is actually changed, emit update so it emits the updated content - if (isUpdate) { - self.emit('update'); + // After the content is actually changed, emit update so it emits the updated content + if (isUpdate) { + self.emit('update'); + // Emit a private update event so it can't get accidentally removed + self.emit('__update'); + } + + if (_isAuto) { + this.emit('autosave'); + } + else if (!_isPreviewDraft) { + this.emit('save'); + } } - this.emit('save'); return this; } /** * Removes a page @@ -1222,59 +1587,112 @@ if (self.is('fullscreen')) { self.preview(); } + //firefox has trouble with importing and working out the size right away + if (self.settings.autogrow) { + setTimeout(function () { + self._autogrow(); + }, 50); + } + return this; }; /** + * Gets the local filestore + * @param {string} name Name of the file in the store + * @returns {object|undefined} the local filestore, or a specific file in the store, if a name is given + */ + EpicEditor.prototype._getFileStore = function (name, _isPreviewDraft) { + var previewDraftName = '' + , store; + if (_isPreviewDraft) { + previewDraftName = this._previewDraftLocation; + } + store = JSON.parse(this._storage[previewDraftName + this.settings.localStorageName]); + if (name) { + return store[name]; + } + else { + return store; + } + } + + /** * Exports a file as a string in a supported format * @param {string} name Name of the file you want to export (case sensitive) - * @param {string} kind Kind of file you want the content in (currently supports html and text) + * @param {string} kind Kind of file you want the content in (currently supports html and text, default is the format the browser "wants") * @returns {string|undefined} The content of the file in the content given or undefined if it doesn't exist */ - EpicEditor.prototype.exportFile = function (name, kind) { + EpicEditor.prototype.exportFile = function (name, kind, _isPreviewDraft) { var self = this , file , content; name = name || self.settings.file.name; kind = kind || 'text'; - file = self.getFiles(name); + file = self._getFileStore(name, _isPreviewDraft); // If the file doesn't exist just return early with undefined if (file === undefined) { return; } content = file.content; switch (kind) { case 'html': - // Get this, 2 spaces in a content editable actually converts to: - // 0020 00a0, meaning, "space no-break space". So, manually convert - // no-break spaces to spaces again before handing to marked. - // Also, WebKit converts no-break to unicode equivalent and FF HTML. - content = content.replace(/\u00a0/g, ' ').replace(/&nbsp;/g, ' '); + content = _sanitizeRawContent(content); return self.settings.parser(content); case 'text': - content = content.replace(/\u00a0/g, ' ').replace(/&nbsp;/g, ' '); + return _sanitizeRawContent(content); + case 'json': + file.content = _sanitizeRawContent(file.content); + return JSON.stringify(file); + case 'raw': return content; default: return content; } } - EpicEditor.prototype.getFiles = function (name) { - var files = JSON.parse(this._storage[this.settings.localStorageName]); + /** + * Gets the contents and metadata for files + * @param {string} name Name of the file whose data you want (case sensitive) + * @param {boolean} excludeContent whether the contents of files should be excluded + * @returns {object} An object with the names and data of every file, or just the data of one file if a name was given + */ + EpicEditor.prototype.getFiles = function (name, excludeContent) { + var file + , data = this._getFileStore(name); + if (name) { - return files[name]; + if (data !== undefined) { + if (excludeContent) { + delete data.content; + } + else { + data.content = _sanitizeRawContent(data.content); + } + } + return data; } else { - return files; + for (file in data) { + if (data.hasOwnProperty(file)) { + if (excludeContent) { + delete data[file].content; + } + else { + data[file].content = _sanitizeRawContent(data[file].content); + } + } + } + return data; } } // EVENTS // TODO: Support for namespacing events like "preview.foo" @@ -1338,21 +1756,102 @@ // Otherwise a handler and event exist, so take care of it this.events[ev].splice(this.events[ev].indexOf(handler), 1); return self; } - EpicEditor.version = '0.2.0'; + /** + * Handles autogrowing the editor + */ + EpicEditor.prototype._autogrow = function () { + var editorHeight + , newHeight + , minHeight + , maxHeight + , el + , style + , maxedOut = false; + //autogrow in fullscreen is nonsensical + if (!this.is('fullscreen')) { + if (this.is('edit')) { + el = this.getElement('editor').documentElement; + } + else { + el = this.getElement('previewer').documentElement; + } + + editorHeight = _outerHeight(el); + newHeight = editorHeight; + + //handle minimum + minHeight = this.settings.autogrow.minHeight; + if (typeof minHeight === 'function') { + minHeight = minHeight(this); + } + + if (minHeight && newHeight < minHeight) { + newHeight = minHeight; + } + + //handle maximum + maxHeight = this.settings.autogrow.maxHeight; + if (typeof maxHeight === 'function') { + maxHeight = maxHeight(this); + } + + if (maxHeight && newHeight > maxHeight) { + newHeight = maxHeight; + maxedOut = true; + } + + if (maxedOut) { + this._fixScrollbars('auto'); + } else { + this._fixScrollbars('hidden'); + } + + //actual resize + if (newHeight != this.oldHeight) { + this.getElement('container').style.height = newHeight + 'px'; + this.reflow(); + if (this.settings.autogrow.scroll) { + window.scrollBy(0, newHeight - this.oldHeight); + } + this.oldHeight = newHeight; + } + } + } + + /** + * Shows or hides scrollbars based on the autogrow setting + * @param {string} forceSetting a value to force the overflow to + */ + EpicEditor.prototype._fixScrollbars = function (forceSetting) { + var setting; + if (this.settings.autogrow) { + setting = 'hidden'; + } + else { + setting = 'auto'; + } + setting = forceSetting || setting; + this.getElement('editor').documentElement.style.overflow = setting; + this.getElement('previewer').documentElement.style.overflow = setting; + } + + EpicEditor.version = '0.2.2'; + // Used to store information to be shared across editors EpicEditor._data = {}; window.EpicEditor = EpicEditor; })(window); /** - * marked - A markdown parser (https://github.com/chjj/marked) - * Copyright (c) 2011-2012, Christopher Jeffrey. (MIT Licensed) + * marked - a markdown parser + * Copyright (c) 2011-2013, Christopher Jeffrey. (MIT Licensed) + * https://github.com/chjj/marked */ ;(function() { /** @@ -1363,16 +1862,18 @@ newline: /^\n+/, code: /^( {4}[^\n]+\n*)+/, fences: noop, hr: /^( *[-*_]){3,} *(?:\n+|$)/, heading: /^ *(#{1,6}) *([^\n]+?) *#* *(?:\n+|$)/, + nptable: noop, lheading: /^([^\n]+)\n *(=|-){3,} *\n*/, blockquote: /^( *>[^\n]+(\n[^\n]+)*\n*)+/, - list: /^( *)(bull) [^\0]+?(?:hr|\n{2,}(?! )(?!\1bull )\n*|\s*$)/, + list: /^( *)(bull) [\s\S]+?(?:hr|\n{2,}(?! )(?!\1bull )\n*|\s*$)/, html: /^ *(?:comment|closed|closing) *(?:\n{2,}|\s*$)/, def: /^ *\[([^\]]+)\]: *([^\s]+)(?: +["(]([^\n]+)[")])? *(?:\n+|$)/, - paragraph: /^([^\n]+\n?(?!body))+\n*/, + table: noop, + paragraph: /^([^\n]+\n?(?!hr|heading|lheading|blockquote|tag|def))+\n*/, text: /^[^\n]+/ }; block.bullet = /(?:[*+-]|\d+\.)/; block.item = /^( *)(bull) [^\n]*(?:\n(?!\1bull )[^\n]*)*/; @@ -1383,68 +1884,112 @@ block.list = replace(block.list) (/bull/g, block.bullet) ('hr', /\n+(?=(?: *[-*_]){3,} *(?:\n+|$))/) (); +block._tag = '(?!(?:' + + 'a|em|strong|small|s|cite|q|dfn|abbr|data|time|code' + + '|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo' + + '|span|br|wbr|ins|del|img)\\b)\\w+(?!:/|@)\\b'; + block.html = replace(block.html) - ('comment', /<!--[^\0]*?-->/) - ('closed', /<(tag)[^\0]+?<\/\1>/) - ('closing', /<tag(?!:\/|@)\b(?:"[^"]*"|'[^']*'|[^'">])*?>/) - (/tag/g, tag()) + ('comment', /<!--[\s\S]*?-->/) + ('closed', /<(tag)[\s\S]+?<\/\1>/) + ('closing', /<tag(?:"[^"]*"|'[^']*'|[^'">])*?>/) + (/tag/g, block._tag) (); -block.paragraph = (function() { - var paragraph = block.paragraph.source - , body = []; +block.paragraph = replace(block.paragraph) + ('hr', block.hr) + ('heading', block.heading) + ('lheading', block.lheading) + ('blockquote', block.blockquote) + ('tag', '<' + block._tag) + ('def', block.def) + (); - (function push(rule) { - rule = block[rule] ? block[rule].source : rule; - body.push(rule.replace(/(^|[^\[])\^/g, '$1')); - return push; - }) - ('hr') - ('heading') - ('lheading') - ('blockquote') - ('<' + tag()) - ('def'); +/** + * Normal Block Grammar + */ - return new - RegExp(paragraph.replace('body', body.join('|'))); -})(); +block.normal = merge({}, block); -block.normal = { - fences: block.fences, - paragraph: block.paragraph -}; +/** + * GFM Block Grammar + */ -block.gfm = { - fences: /^ *``` *(\w+)? *\n([^\0]+?)\s*``` *(?:\n+|$)/, +block.gfm = merge({}, block.normal, { + fences: /^ *(`{3,}|~{3,}) *(\w+)? *\n([\s\S]+?)\s*\1 *(?:\n+|$)/, paragraph: /^/ -}; +}); block.gfm.paragraph = replace(block.paragraph) - ('(?!', '(?!' + block.gfm.fences.source.replace(/(^|[^\[])\^/g, '$1') + '|') + ('(?!', '(?!' + block.gfm.fences.source.replace('\\1', '\\2') + '|') (); /** + * GFM + Tables Block Grammar + */ + +block.tables = merge({}, block.gfm, { + nptable: /^ *(\S.*\|.*)\n *([-:]+ *\|[-| :]*)\n((?:.*\|.*(?:\n|$))*)\n*/, + table: /^ *\|(.+)\n *\|( *[-:]+[-| :]*)\n((?: *\|.*(?:\n|$))*)\n*/ +}); + +/** * Block Lexer */ -block.lexer = function(src) { - var tokens = []; +function Lexer(options) { + this.tokens = []; + this.tokens.links = {}; + this.options = options || marked.defaults; + this.rules = block.normal; - tokens.links = {}; + if (this.options.gfm) { + if (this.options.tables) { + this.rules = block.tables; + } else { + this.rules = block.gfm; + } + } +} +/** + * Expose Block Rules + */ + +Lexer.rules = block; + +/** + * Static Lex Method + */ + +Lexer.lex = function(src, options) { + var lexer = new Lexer(options); + return lexer.lex(src); +}; + +/** + * Preprocessing + */ + +Lexer.prototype.lex = function(src) { src = src .replace(/\r\n|\r/g, '\n') - .replace(/\t/g, ' '); + .replace(/\t/g, ' ') + .replace(/\u00a0/g, ' ') + .replace(/\u2424/g, '\n'); - return block.token(src, tokens, true); + return this.token(src, true); }; -block.token = function(src, tokens, top) { +/** + * Lexing + */ + +Lexer.prototype.token = function(src, top) { var src = src.replace(/^ +$/gm, '') , next , loose , cap , item @@ -1452,107 +1997,139 @@ , i , l; while (src) { // newline - if (cap = block.newline.exec(src)) { + if (cap = this.rules.newline.exec(src)) { src = src.substring(cap[0].length); if (cap[0].length > 1) { - tokens.push({ + this.tokens.push({ type: 'space' }); } } // code - if (cap = block.code.exec(src)) { + if (cap = this.rules.code.exec(src)) { src = src.substring(cap[0].length); cap = cap[0].replace(/^ {4}/gm, ''); - tokens.push({ + this.tokens.push({ type: 'code', - text: !options.pedantic + text: !this.options.pedantic ? cap.replace(/\n+$/, '') : cap }); continue; } // fences (gfm) - if (cap = block.fences.exec(src)) { + if (cap = this.rules.fences.exec(src)) { src = src.substring(cap[0].length); - tokens.push({ + this.tokens.push({ type: 'code', - lang: cap[1], - text: cap[2] + lang: cap[2], + text: cap[3] }); continue; } // heading - if (cap = block.heading.exec(src)) { + if (cap = this.rules.heading.exec(src)) { src = src.substring(cap[0].length); - tokens.push({ + this.tokens.push({ type: 'heading', depth: cap[1].length, text: cap[2] }); continue; } + // table no leading pipe (gfm) + if (top && (cap = this.rules.nptable.exec(src))) { + src = src.substring(cap[0].length); + + item = { + type: 'table', + header: cap[1].replace(/^ *| *\| *$/g, '').split(/ *\| */), + align: cap[2].replace(/^ *|\| *$/g, '').split(/ *\| */), + cells: cap[3].replace(/\n$/, '').split('\n') + }; + + for (i = 0; i < item.align.length; i++) { + if (/^ *-+: *$/.test(item.align[i])) { + item.align[i] = 'right'; + } else if (/^ *:-+: *$/.test(item.align[i])) { + item.align[i] = 'center'; + } else if (/^ *:-+ *$/.test(item.align[i])) { + item.align[i] = 'left'; + } else { + item.align[i] = null; + } + } + + for (i = 0; i < item.cells.length; i++) { + item.cells[i] = item.cells[i].split(/ *\| */); + } + + this.tokens.push(item); + + continue; + } + // lheading - if (cap = block.lheading.exec(src)) { + if (cap = this.rules.lheading.exec(src)) { src = src.substring(cap[0].length); - tokens.push({ + this.tokens.push({ type: 'heading', depth: cap[2] === '=' ? 1 : 2, text: cap[1] }); continue; } // hr - if (cap = block.hr.exec(src)) { + if (cap = this.rules.hr.exec(src)) { src = src.substring(cap[0].length); - tokens.push({ + this.tokens.push({ type: 'hr' }); continue; } // blockquote - if (cap = block.blockquote.exec(src)) { + if (cap = this.rules.blockquote.exec(src)) { src = src.substring(cap[0].length); - tokens.push({ + this.tokens.push({ type: 'blockquote_start' }); cap = cap[0].replace(/^ *> ?/gm, ''); // Pass `top` to keep the current // "toplevel" state. This is exactly // how markdown.pl works. - block.token(cap, tokens, top); + this.token(cap, top); - tokens.push({ + this.tokens.push({ type: 'blockquote_end' }); continue; } // list - if (cap = block.list.exec(src)) { + if (cap = this.rules.list.exec(src)) { src = src.substring(cap[0].length); - tokens.push({ + this.tokens.push({ type: 'list_start', ordered: isFinite(cap[2]) }); // Get each top-level item. - cap = cap[0].match(block.item); + cap = cap[0].match(this.rules.item); next = false; l = cap.length; i = 0; @@ -1566,11 +2143,11 @@ // Outdent whatever the // list item contains. Hacky. if (~item.indexOf('\n ')) { space -= item.length; - item = !options.pedantic + item = !this.options.pedantic ? item.replace(new RegExp('^ {1,' + space + '}', 'gm'), '') : item.replace(/^ {1,4}/gm, ''); } // Determine whether item is loose or not. @@ -1580,153 +2157,255 @@ if (i !== l - 1) { next = item[item.length-1] === '\n'; if (!loose) loose = next; } - tokens.push({ + this.tokens.push({ type: loose ? 'loose_item_start' : 'list_item_start' }); // Recurse. - block.token(item, tokens); + this.token(item, false); - tokens.push({ + this.tokens.push({ type: 'list_item_end' }); } - tokens.push({ + this.tokens.push({ type: 'list_end' }); continue; } // html - if (cap = block.html.exec(src)) { + if (cap = this.rules.html.exec(src)) { src = src.substring(cap[0].length); - tokens.push({ - type: 'html', + this.tokens.push({ + type: this.options.sanitize + ? 'paragraph' + : 'html', pre: cap[1] === 'pre', text: cap[0] }); continue; } // def - if (top && (cap = block.def.exec(src))) { + if (top && (cap = this.rules.def.exec(src))) { src = src.substring(cap[0].length); - tokens.links[cap[1].toLowerCase()] = { + this.tokens.links[cap[1].toLowerCase()] = { href: cap[2], title: cap[3] }; continue; } + // table (gfm) + if (top && (cap = this.rules.table.exec(src))) { + src = src.substring(cap[0].length); + + item = { + type: 'table', + header: cap[1].replace(/^ *| *\| *$/g, '').split(/ *\| */), + align: cap[2].replace(/^ *|\| *$/g, '').split(/ *\| */), + cells: cap[3].replace(/(?: *\| *)?\n$/, '').split('\n') + }; + + for (i = 0; i < item.align.length; i++) { + if (/^ *-+: *$/.test(item.align[i])) { + item.align[i] = 'right'; + } else if (/^ *:-+: *$/.test(item.align[i])) { + item.align[i] = 'center'; + } else if (/^ *:-+ *$/.test(item.align[i])) { + item.align[i] = 'left'; + } else { + item.align[i] = null; + } + } + + for (i = 0; i < item.cells.length; i++) { + item.cells[i] = item.cells[i] + .replace(/^ *\| *| *\| *$/g, '') + .split(/ *\| */); + } + + this.tokens.push(item); + + continue; + } + // top-level paragraph - if (top && (cap = block.paragraph.exec(src))) { + if (top && (cap = this.rules.paragraph.exec(src))) { src = src.substring(cap[0].length); - tokens.push({ + this.tokens.push({ type: 'paragraph', text: cap[0] }); continue; } // text - if (cap = block.text.exec(src)) { + if (cap = this.rules.text.exec(src)) { // Top-level should never reach here. src = src.substring(cap[0].length); - tokens.push({ + this.tokens.push({ type: 'text', text: cap[0] }); continue; } + + if (src) { + throw new + Error('Infinite loop on byte: ' + src.charCodeAt(0)); + } } - return tokens; + return this.tokens; }; /** - * Inline Processing + * Inline-Level Grammar */ var inline = { - escape: /^\\([\\`*{}\[\]()#+\-.!_>])/, + escape: /^\\([\\`*{}\[\]()#+\-.!_>|])/, autolink: /^<([^ >]+(@|:\/)[^ >]+)>/, url: noop, - tag: /^<!--[^\0]*?-->|^<\/?\w+(?:"[^"]*"|'[^']*'|[^'">])*?>/, + tag: /^<!--[\s\S]*?-->|^<\/?\w+(?:"[^"]*"|'[^']*'|[^'">])*?>/, link: /^!?\[(inside)\]\(href\)/, reflink: /^!?\[(inside)\]\s*\[([^\]]*)\]/, nolink: /^!?\[((?:\[[^\]]*\]|[^\[\]])*)\]/, - strong: /^__([^\0]+?)__(?!_)|^\*\*([^\0]+?)\*\*(?!\*)/, - em: /^\b_((?:__|[^\0])+?)_\b|^\*((?:\*\*|[^\0])+?)\*(?!\*)/, - code: /^(`+)([^\0]*?[^`])\1(?!`)/, + strong: /^__([\s\S]+?)__(?!_)|^\*\*([\s\S]+?)\*\*(?!\*)/, + em: /^\b_((?:__|[\s\S])+?)_\b|^\*((?:\*\*|[\s\S])+?)\*(?!\*)/, + code: /^(`+)([\s\S]*?[^`])\1(?!`)/, br: /^ {2,}\n(?!\s*$)/, - text: /^[^\0]+?(?=[\\<!\[_*`]| {2,}\n|$)/ + del: noop, + text: /^[\s\S]+?(?=[\\<!\[_*`]| {2,}\n|$)/ }; -inline._linkInside = /(?:\[[^\]]*\]|[^\]]|\](?=[^\[]*\]))*/; -inline._linkHref = /\s*<?([^\s]*?)>?(?:\s+['"]([^\0]*?)['"])?\s*/; +inline._inside = /(?:\[[^\]]*\]|[^\]]|\](?=[^\[]*\]))*/; +inline._href = /\s*<?([^\s]*?)>?(?:\s+['"]([\s\S]*?)['"])?\s*/; inline.link = replace(inline.link) - ('inside', inline._linkInside) - ('href', inline._linkHref) + ('inside', inline._inside) + ('href', inline._href) (); inline.reflink = replace(inline.reflink) - ('inside', inline._linkInside) + ('inside', inline._inside) (); -inline.normal = { - url: inline.url, - strong: inline.strong, - em: inline.em, - text: inline.text -}; +/** + * Normal Inline Grammar + */ -inline.pedantic = { - strong: /^__(?=\S)([^\0]*?\S)__(?!_)|^\*\*(?=\S)([^\0]*?\S)\*\*(?!\*)/, - em: /^_(?=\S)([^\0]*?\S)_(?!_)|^\*(?=\S)([^\0]*?\S)\*(?!\*)/ -}; +inline.normal = merge({}, inline); -inline.gfm = { +/** + * Pedantic Inline Grammar + */ + +inline.pedantic = merge({}, inline.normal, { + strong: /^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/, + em: /^_(?=\S)([\s\S]*?\S)_(?!_)|^\*(?=\S)([\s\S]*?\S)\*(?!\*)/ +}); + +/** + * GFM Inline Grammar + */ + +inline.gfm = merge({}, inline.normal, { + escape: replace(inline.escape)('])', '~])')(), url: /^(https?:\/\/[^\s]+[^.,:;"')\]\s])/, - text: /^[^\0]+?(?=[\\<!\[_*`]|https?:\/\/| {2,}\n|$)/ + del: /^~{2,}([\s\S]+?)~{2,}/, + text: replace(inline.text) + (']|', '~]|') + ('|', '|https?://|') + () +}); + +/** + * GFM + Line Breaks Inline Grammar + */ + +inline.breaks = merge({}, inline.gfm, { + br: replace(inline.br)('{2,}', '*')(), + text: replace(inline.gfm.text)('{2,}', '*')() +}); + +/** + * Inline Lexer & Compiler + */ + +function InlineLexer(links, options) { + this.options = options || marked.defaults; + this.links = links; + this.rules = inline.normal; + + if (!this.links) { + throw new + Error('Tokens array requires a `links` property.'); + } + + if (this.options.gfm) { + if (this.options.breaks) { + this.rules = inline.breaks; + } else { + this.rules = inline.gfm; + } + } else if (this.options.pedantic) { + this.rules = inline.pedantic; + } +} + +/** + * Expose Inline Rules + */ + +InlineLexer.rules = inline; + +/** + * Static Lexing/Compiling Method + */ + +InlineLexer.output = function(src, links, opt) { + var inline = new InlineLexer(links, opt); + return inline.output(src); }; /** - * Inline Lexer + * Lexing/Compiling */ -inline.lexer = function(src) { +InlineLexer.prototype.output = function(src) { var out = '' - , links = tokens.links , link , text , href , cap; while (src) { // escape - if (cap = inline.escape.exec(src)) { + if (cap = this.rules.escape.exec(src)) { src = src.substring(cap[0].length); out += cap[1]; continue; } // autolink - if (cap = inline.autolink.exec(src)) { + if (cap = this.rules.autolink.exec(src)) { src = src.substring(cap[0].length); if (cap[2] === '@') { text = cap[1][6] === ':' - ? mangle(cap[1].substring(7)) - : mangle(cap[1]); - href = mangle('mailto:') + text; + ? this.mangle(cap[1].substring(7)) + : this.mangle(cap[1]); + href = this.mangle('mailto:') + text; } else { text = escape(cap[1]); href = text; } out += '<a href="' @@ -1736,11 +2415,11 @@ + '</a>'; continue; } // url (gfm) - if (cap = inline.url.exec(src)) { + if (cap = this.rules.url.exec(src)) { src = src.substring(cap[0].length); text = escape(cap[1]); href = text; out += '<a href="' + href @@ -1749,100 +2428,118 @@ + '</a>'; continue; } // tag - if (cap = inline.tag.exec(src)) { + if (cap = this.rules.tag.exec(src)) { src = src.substring(cap[0].length); - out += options.sanitize + out += this.options.sanitize ? escape(cap[0]) : cap[0]; continue; } // link - if (cap = inline.link.exec(src)) { + if (cap = this.rules.link.exec(src)) { src = src.substring(cap[0].length); - out += outputLink(cap, { + out += this.outputLink(cap, { href: cap[2], title: cap[3] }); continue; } // reflink, nolink - if ((cap = inline.reflink.exec(src)) - || (cap = inline.nolink.exec(src))) { + if ((cap = this.rules.reflink.exec(src)) + || (cap = this.rules.nolink.exec(src))) { src = src.substring(cap[0].length); link = (cap[2] || cap[1]).replace(/\s+/g, ' '); - link = links[link.toLowerCase()]; + link = this.links[link.toLowerCase()]; if (!link || !link.href) { out += cap[0][0]; src = cap[0].substring(1) + src; continue; } - out += outputLink(cap, link); + out += this.outputLink(cap, link); continue; } // strong - if (cap = inline.strong.exec(src)) { + if (cap = this.rules.strong.exec(src)) { src = src.substring(cap[0].length); out += '<strong>' - + inline.lexer(cap[2] || cap[1]) + + this.output(cap[2] || cap[1]) + '</strong>'; continue; } // em - if (cap = inline.em.exec(src)) { + if (cap = this.rules.em.exec(src)) { src = src.substring(cap[0].length); out += '<em>' - + inline.lexer(cap[2] || cap[1]) + + this.output(cap[2] || cap[1]) + '</em>'; continue; } // code - if (cap = inline.code.exec(src)) { + if (cap = this.rules.code.exec(src)) { src = src.substring(cap[0].length); out += '<code>' + escape(cap[2], true) + '</code>'; continue; } // br - if (cap = inline.br.exec(src)) { + if (cap = this.rules.br.exec(src)) { src = src.substring(cap[0].length); out += '<br>'; continue; } + // del (gfm) + if (cap = this.rules.del.exec(src)) { + src = src.substring(cap[0].length); + out += '<del>' + + this.output(cap[1]) + + '</del>'; + continue; + } + // text - if (cap = inline.text.exec(src)) { + if (cap = this.rules.text.exec(src)) { src = src.substring(cap[0].length); out += escape(cap[0]); continue; } + + if (src) { + throw new + Error('Infinite loop on byte: ' + src.charCodeAt(0)); + } } return out; }; -function outputLink(cap, link) { +/** + * Compile Link + */ + +InlineLexer.prototype.outputLink = function(cap, link) { if (cap[0][0] !== '!') { return '<a href="' + escape(link.href) + '"' + (link.title ? ' title="' + escape(link.title) + '"' : '') + '>' - + inline.lexer(cap[1]) + + this.output(cap[1]) + '</a>'; } else { return '<img src="' + escape(link.href) + '" alt="' @@ -1853,80 +2550,196 @@ + escape(link.title) + '"' : '') + '>'; } -} +}; /** - * Parsing + * Mangle Links */ -var tokens - , token; +InlineLexer.prototype.mangle = function(text) { + var out = '' + , l = text.length + , i = 0 + , ch; -function next() { - return token = tokens.pop(); + for (; i < l; i++) { + ch = text.charCodeAt(i); + if (Math.random() > 0.5) { + ch = 'x' + ch.toString(16); + } + out += '&#' + ch + ';'; + } + + return out; +}; + +/** + * Parsing & Compiling + */ + +function Parser(options) { + this.tokens = []; + this.token = null; + this.options = options || marked.defaults; } -function tok() { - switch (token.type) { +/** + * Static Parse Method + */ + +Parser.parse = function(src, options) { + var parser = new Parser(options); + return parser.parse(src); +}; + +/** + * Parse Loop + */ + +Parser.prototype.parse = function(src) { + this.inline = new InlineLexer(src.links, this.options); + this.tokens = src.reverse(); + + var out = ''; + while (this.next()) { + out += this.tok(); + } + + return out; +}; + +/** + * Next Token + */ + +Parser.prototype.next = function() { + return this.token = this.tokens.pop(); +}; + +/** + * Preview Next Token + */ + +Parser.prototype.peek = function() { + return this.tokens[this.tokens.length-1] || 0; +}; + +/** + * Parse Text Tokens + */ + +Parser.prototype.parseText = function() { + var body = this.token.text; + + while (this.peek().type === 'text') { + body += '\n' + this.next().text; + } + + return this.inline.output(body); +}; + +/** + * Parse Current Token + */ + +Parser.prototype.tok = function() { + switch (this.token.type) { case 'space': { return ''; } case 'hr': { return '<hr>\n'; } case 'heading': { return '<h' - + token.depth + + this.token.depth + '>' - + inline.lexer(token.text) + + this.inline.output(this.token.text) + '</h' - + token.depth + + this.token.depth + '>\n'; } case 'code': { - if (options.highlight) { - token.code = options.highlight(token.text, token.lang); - if (token.code != null && token.code !== token.text) { - token.escaped = true; - token.text = token.code; + if (this.options.highlight) { + var code = this.options.highlight(this.token.text, this.token.lang); + if (code != null && code !== this.token.text) { + this.token.escaped = true; + this.token.text = code; } } - if (!token.escaped) { - token.text = escape(token.text, true); + if (!this.token.escaped) { + this.token.text = escape(this.token.text, true); } return '<pre><code' - + (token.lang + + (this.token.lang ? ' class="lang-' - + token.lang + + this.token.lang + '"' : '') + '>' - + token.text + + this.token.text + '</code></pre>\n'; } + case 'table': { + var body = '' + , heading + , i + , row + , cell + , j; + + // header + body += '<thead>\n<tr>\n'; + for (i = 0; i < this.token.header.length; i++) { + heading = this.inline.output(this.token.header[i]); + body += this.token.align[i] + ? '<th align="' + this.token.align[i] + '">' + heading + '</th>\n' + : '<th>' + heading + '</th>\n'; + } + body += '</tr>\n</thead>\n'; + + // body + body += '<tbody>\n' + for (i = 0; i < this.token.cells.length; i++) { + row = this.token.cells[i]; + body += '<tr>\n'; + for (j = 0; j < row.length; j++) { + cell = this.inline.output(row[j]); + body += this.token.align[j] + ? '<td align="' + this.token.align[j] + '">' + cell + '</td>\n' + : '<td>' + cell + '</td>\n'; + } + body += '</tr>\n'; + } + body += '</tbody>\n'; + + return '<table>\n' + + body + + '</table>\n'; + } case 'blockquote_start': { var body = ''; - while (next().type !== 'blockquote_end') { - body += tok(); + while (this.next().type !== 'blockquote_end') { + body += this.tok(); } return '<blockquote>\n' + body + '</blockquote>\n'; } case 'list_start': { - var type = token.ordered ? 'ol' : 'ul' + var type = this.token.ordered ? 'ol' : 'ul' , body = ''; - while (next().type !== 'list_end') { - body += tok(); + while (this.next().type !== 'list_end') { + body += this.tok(); } return '<' + type + '>\n' @@ -1936,78 +2749,49 @@ + '>\n'; } case 'list_item_start': { var body = ''; - while (next().type !== 'list_item_end') { - body += token.type === 'text' - ? parseText() - : tok(); + while (this.next().type !== 'list_item_end') { + body += this.token.type === 'text' + ? this.parseText() + : this.tok(); } return '<li>' + body + '</li>\n'; } case 'loose_item_start': { var body = ''; - while (next().type !== 'list_item_end') { - body += tok(); + while (this.next().type !== 'list_item_end') { + body += this.tok(); } return '<li>' + body + '</li>\n'; } case 'html': { - if (options.sanitize) { - return inline.lexer(token.text); - } - return !token.pre && !options.pedantic - ? inline.lexer(token.text) - : token.text; + return !this.token.pre && !this.options.pedantic + ? this.inline.output(this.token.text) + : this.token.text; } case 'paragraph': { return '<p>' - + inline.lexer(token.text) + + this.inline.output(this.token.text) + '</p>\n'; } case 'text': { return '<p>' - + parseText() + + this.parseText() + '</p>\n'; } } -} +}; -function parseText() { - var body = token.text - , top; - - while ((top = tokens[tokens.length-1]) - && top.type === 'text') { - body += '\n' + next().text; - } - - return inline.lexer(body); -} - -function parse(src) { - tokens = src.reverse(); - - var out = ''; - while (next()) { - out += tok(); - } - - tokens = null; - token = null; - - return out; -} - /** * Helpers */ function escape(html, encode) { @@ -2017,122 +2801,96 @@ .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;'); } -function mangle(text) { - var out = '' - , l = text.length - , i = 0 - , ch; - - for (; i < l; i++) { - ch = text.charCodeAt(i); - if (Math.random() > 0.5) { - ch = 'x' + ch.toString(16); - } - out += '&#' + ch + ';'; - } - - return out; -} - -function tag() { - var tag = '(?!(?:' - + 'a|em|strong|small|s|cite|q|dfn|abbr|data|time|code' - + '|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo' - + '|span|br|wbr|ins|del|img)\\b)\\w+'; - - return tag; -} - function replace(regex, opt) { regex = regex.source; opt = opt || ''; return function self(name, val) { if (!name) return new RegExp(regex, opt); - regex = regex.replace(name, val.source || val); + val = val.source || val; + val = val.replace(/(^|[^\[])\^/g, '$1'); + regex = regex.replace(name, val); return self; }; } function noop() {} noop.exec = noop; +function merge(obj) { + var i = 1 + , target + , key; + + for (; i < arguments.length; i++) { + target = arguments[i]; + for (key in target) { + if (Object.prototype.hasOwnProperty.call(target, key)) { + obj[key] = target[key]; + } + } + } + + return obj; +} + /** * Marked */ function marked(src, opt) { - setOptions(opt); - return parse(block.lexer(src)); + try { + return Parser.parse(Lexer.lex(src, opt), opt); + } catch (e) { + e.message += '\nPlease report this to https://github.com/chjj/marked.'; + if ((opt || marked.defaults).silent) { + return 'An error occured:\n' + e.message; + } + throw e; + } } /** * Options */ -var options - , defaults; - -function setOptions(opt) { - if (!opt) opt = defaults; - if (options === opt) return; - options = opt; - - if (options.gfm) { - block.fences = block.gfm.fences; - block.paragraph = block.gfm.paragraph; - inline.text = inline.gfm.text; - inline.url = inline.gfm.url; - } else { - block.fences = block.normal.fences; - block.paragraph = block.normal.paragraph; - inline.text = inline.normal.text; - inline.url = inline.normal.url; - } - - if (options.pedantic) { - inline.em = inline.pedantic.em; - inline.strong = inline.pedantic.strong; - } else { - inline.em = inline.normal.em; - inline.strong = inline.normal.strong; - } -} - marked.options = marked.setOptions = function(opt) { - defaults = opt; - setOptions(opt); + marked.defaults = opt; return marked; }; -marked.setOptions({ +marked.defaults = { gfm: true, + tables: true, + breaks: false, pedantic: false, sanitize: false, + silent: false, highlight: null -}); +}; /** * Expose */ -marked.parser = function(src, opt) { - setOptions(opt); - return parse(src); -}; +marked.Parser = Parser; +marked.parser = Parser.parse; -marked.lexer = function(src, opt) { - setOptions(opt); - return block.lexer(src); -}; +marked.Lexer = Lexer; +marked.lexer = Lexer.lex; +marked.InlineLexer = InlineLexer; +marked.inlineLexer = InlineLexer.output; + marked.parse = marked; if (typeof module !== 'undefined') { module.exports = marked; +} else if (typeof define === 'function' && define.amd) { + define(function() { return marked; }); } else { this.marked = marked; } }).call(function() {