Git based wiki inspired by Gollum
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

698 lines
18KB

  1. $(function(){
  2. // Cache some shit
  3. var $theme = $('#theme-list')
  4. , $preview = $('#preview')
  5. , $autosave = $('#autosave')
  6. , $wordcount = $('#wordcount')
  7. , $wordcounter = $('#wordcounter')
  8. , $pagename = $("#page-name");
  9. var editor
  10. , autoInterval
  11. , profile =
  12. {
  13. theme: 'ace/theme/idle_fingers'
  14. , currentMd: ''
  15. , autosave:
  16. {
  17. enabled: true
  18. , interval: 3000 // might be too aggressive; don't want to block UI for large saves.
  19. }
  20. , current_filename : $pagename.val()
  21. };
  22. // Feature detect ish
  23. var dillinger = 'dillinger'
  24. , dillingerElem = document.createElement(dillinger)
  25. , dillingerStyle = dillingerElem.style
  26. , domPrefixes = 'Webkit Moz O ms Khtml'.split(' ');
  27. /// UTILS =================
  28. /**
  29. * Utility method to async load a JavaScript file.
  30. *
  31. * @param {String} The name of the file to load
  32. * @param {Function} Optional callback to be executed after the script loads.
  33. * @return {void}
  34. */
  35. function asyncLoad(filename,cb){
  36. (function(d,t){
  37. var leScript = d.createElement(t)
  38. , scripts = d.getElementsByTagName(t)[0];
  39. leScript.async = 1;
  40. leScript.src = filename;
  41. scripts.parentNode.insertBefore(leScript,scripts);
  42. leScript.onload = function(){
  43. cb && cb();
  44. }
  45. }(document,'script'));
  46. }
  47. /**
  48. * Utility method to determin if localStorage is supported or not.
  49. *
  50. * @return {Boolean}
  51. */
  52. function hasLocalStorage(){
  53. // http://mathiasbynens.be/notes/localstorage-pattern
  54. var storage;
  55. try{ if(localStorage.getItem) {storage = localStorage} }catch(e){}
  56. return storage;
  57. }
  58. /**
  59. * Grab the user's profile from localStorage and stash in "profile" variable.
  60. *
  61. * @return {Void}
  62. */
  63. function getUserProfile(){
  64. var p;
  65. try{
  66. p = JSON.parse( localStorage.profile );
  67. // Need to merge in any undefined/new properties from last release
  68. // Meaning, if we add new features they may not have them in profile
  69. p = $.extend(true, profile, p);
  70. }catch(e){
  71. p = profile
  72. }
  73. if (p.filename != $pagename.val()) {
  74. updateUserProfile({ filename: $pagename.val(), currentMd: "" });
  75. }
  76. profile = p;
  77. }
  78. /**
  79. * Update user's profile in localStorage by merging in current profile with passed in param.
  80. *
  81. * @param {Object} An object containg proper keys and values to be JSON.stringify'd
  82. * @return {Void}
  83. */
  84. function updateUserProfile(obj){
  85. localStorage.clear();
  86. localStorage.profile = JSON.stringify( $.extend(true, profile, obj) );
  87. }
  88. /**
  89. * Utility method to test if particular property is supported by the browser or not.
  90. * Completely ripped from Modernizr with some mods.
  91. * Thx, Modernizr team!
  92. *
  93. * @param {String} The property to test
  94. * @return {Boolean}
  95. */
  96. function prefixed(prop){ return testPropsAll(prop, 'pfx') }
  97. /**
  98. * A generic CSS / DOM property test; if a browser supports
  99. * a certain property, it won't return undefined for it.
  100. * A supported CSS property returns empty string when its not yet set.
  101. *
  102. * @param {Object} A hash of properties to test
  103. * @param {String} A prefix
  104. * @return {Boolean}
  105. */
  106. function testProps( props, prefixed ) {
  107. for ( var i in props ) {
  108. if( dillingerStyle[ props[i] ] !== undefined ) {
  109. return prefixed === 'pfx' ? props[i] : true;
  110. }
  111. }
  112. return false
  113. }
  114. /**
  115. * Tests a list of DOM properties we want to check against.
  116. * We specify literally ALL possible (known and/or likely) properties on
  117. * the element including the non-vendor prefixed one, for forward-
  118. * compatibility.
  119. *
  120. * @param {String} The name of the property
  121. * @param {String} The prefix string
  122. * @return {Boolean}
  123. */
  124. function testPropsAll( prop, prefixed ) {
  125. var ucProp = prop.charAt(0).toUpperCase() + prop.substr(1)
  126. , props = (prop + ' ' + domPrefixes.join(ucProp + ' ') + ucProp).split(' ');
  127. return testProps(props, prefixed);
  128. }
  129. /**
  130. * Normalize the transitionEnd event across browsers.
  131. *
  132. * @return {String}
  133. */
  134. function normalizeTransitionEnd()
  135. {
  136. var transEndEventNames =
  137. {
  138. 'WebkitTransition' : 'webkitTransitionEnd'
  139. , 'MozTransition' : 'transitionend'
  140. , 'OTransition' : 'oTransitionEnd'
  141. , 'msTransition' : 'msTransitionEnd' // maybe?
  142. , 'transition' : 'transitionend'
  143. };
  144. return transEndEventNames[ prefixed('transition') ];
  145. }
  146. /**
  147. * Get current filename from contenteditable field.
  148. *
  149. * @return {String}
  150. */
  151. function getCurrentFilenameFromField(){
  152. return $('#filename > span[contenteditable="true"]').text()
  153. }
  154. /**
  155. * Set current filename from profile.
  156. *
  157. * @param {String} Optional string to force set the value.
  158. * @return {String}
  159. */
  160. function setCurrentFilenameField(str){
  161. $('#filename > span[contenteditable="true"]').text( str || profile.current_filename || "Untitled Document")
  162. }
  163. /**
  164. * Returns the full text of an element and all its children.
  165. * The script recursively traverses all text nodes, and returns a
  166. * concatenated string of all texts.
  167. *
  168. * Taken from
  169. * http://stackoverflow.com/questions/2653670/innertext-textcontent-vs-retrieving-each-text-node
  170. *
  171. * @param node
  172. * @return {int}
  173. */
  174. function getTextInElement(node) {
  175. if (node.nodeType === 3) {
  176. return node.data;
  177. }
  178. var txt = '';
  179. if (node = node.firstChild) do {
  180. txt += getTextInElement(node);
  181. } while (node = node.nextSibling);
  182. return txt;
  183. }
  184. /**
  185. * Counts the words in a string
  186. *
  187. * @param string
  188. * @return int
  189. */
  190. function countWords(string) {
  191. var words = string.replace(/W+/g, ' ').match(/\S+/g);
  192. return words && words.length || 0;
  193. }
  194. /**
  195. * Initialize application.
  196. *
  197. * @return {Void}
  198. */
  199. function init(){
  200. if( !hasLocalStorage() ) { sadPanda() }
  201. else{
  202. // Attach to jQuery support object for later use.
  203. $.support.transitionEnd = normalizeTransitionEnd();
  204. getUserProfile();
  205. initAce();
  206. initUi();
  207. bindPreview();
  208. bindNav();
  209. bindKeyboard();
  210. autoSave();
  211. }
  212. }
  213. /**
  214. * Initialize theme and other options of Ace editor.
  215. *
  216. * @return {Void}
  217. */
  218. function initAce(){
  219. editor = ace.edit("editor");
  220. }
  221. /**
  222. * Initialize various UI elements based on userprofile data.
  223. *
  224. * @return {Void}
  225. */
  226. function initUi(){
  227. // Set proper theme value in theme dropdown
  228. fetchTheme(profile.theme, function(){
  229. $theme.find('li > a[data-value="'+profile.theme+'"]').addClass('selected');
  230. editor.getSession().setUseWrapMode(true);
  231. editor.setShowPrintMargin(false);
  232. editor.getSession().setMode('ace/mode/markdown');
  233. editor.getSession().setValue( profile.currentMd || editor.getSession().getValue());
  234. // Immediately populate the preview <div>
  235. previewMd();
  236. });
  237. // Set text for dis/enable autosave / word counter
  238. $autosave.html( profile.autosave.enabled ? '<i class="icon-remove"></i>&nbsp;Disable Autosave' : '<i class="icon-ok"></i>&nbsp;Enable Autosave' );
  239. $wordcount.html( !profile.wordcount ? '<i class="icon-remove"></i>&nbsp;Disabled Word Count' : '<i class="icon-ok"></i>&nbsp;Enabled Word Count' );
  240. setCurrentFilenameField();
  241. /* BEGIN RE-ARCH STUFF */
  242. $('.dropdown-toggle').dropdown();
  243. /* END RE-ARCH STUFF */
  244. }
  245. /// HANDLERS =================
  246. /**
  247. * Clear the markdown and text and the subsequent HTML preview.
  248. *
  249. * @return {Void}
  250. */
  251. function clearSelection(){
  252. editor.getSession().setValue("");
  253. previewMd();
  254. }
  255. // TODO: WEBSOCKET MESSAGE?
  256. /**
  257. * Save the markdown via localStorage - isManual is from a click or key event.
  258. *
  259. * @param {Boolean}
  260. * @return {Void}
  261. */
  262. function saveFile(isManual){
  263. updateUserProfile({currentMd: editor.getSession().getValue()})
  264. if (isManual) {
  265. updateUserProfile({ currentMd: "" });
  266. var data = {
  267. name: $pagename.val(),
  268. message: $("#page-message").val(),
  269. content: editor.getSession().getValue()
  270. };
  271. $.post(window.location, data, function(){
  272. location.href = "/" + data['name'];
  273. });
  274. }
  275. }
  276. /**
  277. * Enable autosave for a specific interval.
  278. *
  279. * @return {Void}
  280. */
  281. function autoSave(){
  282. if(profile.autosave.enabled){
  283. autoInterval = setInterval( function(){
  284. // firefox barfs if I don't pass in anon func to setTimeout.
  285. saveFile();
  286. }, profile.autosave.interval);
  287. }
  288. else{
  289. clearInterval( autoInterval )
  290. }
  291. }
  292. $("#save-native").on('click', function() {
  293. saveFile(true);
  294. });
  295. /**
  296. * Clear out user profile data in localStorage.
  297. *
  298. * @return {Void}
  299. */
  300. function resetProfile(){
  301. // For some reason, clear() is not working in Chrome.
  302. localStorage.clear();
  303. // Let's turn off autosave
  304. profile.autosave.enabled = false
  305. // Delete the property altogether --> need ; for JSHint bug.
  306. ; delete localStorage.profile;
  307. // Now reload the page to start fresh
  308. window.location.reload();
  309. }
  310. /**
  311. * Dropbown nav handler to update the current theme.
  312. *
  313. * @return {Void}
  314. */
  315. function changeTheme(e){
  316. // check for same theme
  317. var $target = $(e.target);
  318. if( $target.attr('data-value') === profile.theme) { return; }
  319. else{
  320. // add/remove class
  321. $theme.find('li > a.selected').removeClass('selected');
  322. $target.addClass('selected');
  323. // grabnew theme
  324. var newTheme = $target.attr('data-value');
  325. $(e.target).blur();
  326. fetchTheme(newTheme, function(){
  327. });
  328. }
  329. }
  330. // TODO: Maybe we just load them all once and stash in appcache?
  331. /**
  332. * Dynamically appends a script tag with the proper theme and then applies that theme.
  333. *
  334. * @param {String} The theme name
  335. * @param {Function} Optional callback
  336. * @return {Void}
  337. */
  338. function fetchTheme(th, cb){
  339. var name = th.split('/').pop();
  340. asyncLoad("/static/js/ace/theme-"+ name +".js", function(){
  341. editor.setTheme(th);
  342. cb && cb();
  343. updateBg(name);
  344. updateUserProfile({theme: th});
  345. }); // end asyncLoad
  346. } // end fetchTheme(t)
  347. /**
  348. * Change the body background color based on theme.
  349. *
  350. * @param {String} The theme name
  351. * @return {Void}
  352. */
  353. function updateBg(name){
  354. // document.body.style.backgroundColor = bgColors[name]
  355. }
  356. /**
  357. * Clientside update showing rendered HTML of Markdown.
  358. *
  359. * @return {Void}
  360. */
  361. function previewMd(){
  362. var unmd = editor.getSession().getValue()
  363. , md = MDR.convert(unmd, true);
  364. $preview
  365. .html('') // unnecessary?
  366. .html(md);
  367. refreshWordCount();
  368. }
  369. function refreshWordCount(selectionCount){
  370. var msg = "Words: ";
  371. if (selectionCount !== undefined) {
  372. msg += selectionCount + " of ";
  373. }
  374. if(profile.wordcount){
  375. $wordcounter.text(msg + countWords(getTextInElement($preview[0])));
  376. }
  377. }
  378. /**
  379. * Stash current file name in the user's profile.
  380. *
  381. * @param {String} Optional string to force the value
  382. * @return {Void}
  383. */
  384. function updateFilename(str){
  385. // Check for string because it may be keyup event object
  386. var f
  387. if(typeof str === 'string'){
  388. f = str
  389. }else
  390. {
  391. f = getCurrentFilenameFromField()
  392. }
  393. updateUserProfile( {current_filename: f })
  394. }
  395. function showHtml(){
  396. // TODO: UPDATE TO SUPPORT FILENAME NOT JUST A RANDOM FILENAME
  397. var unmd = editor.getSession().getValue();
  398. function _doneHandler(jqXHR, data, response){
  399. // console.dir(resp)
  400. var resp = JSON.parse(response.responseText);
  401. $('#myModalBody').text(resp.data);
  402. $('#myModal').modal();
  403. }
  404. function _failHandler(){
  405. alert("Roh-roh. Something went wrong. :(");
  406. }
  407. var config = {
  408. type: 'POST',
  409. data: "unmd=" + encodeURIComponent(unmd),
  410. dataType: 'json',
  411. url: '/factory/fetch_html_direct',
  412. error: _failHandler,
  413. success: _doneHandler
  414. };
  415. $.ajax(config)
  416. }
  417. /**
  418. * Show a sad panda because they are using a shitty browser.
  419. *
  420. * @return {Void}
  421. */
  422. function sadPanda(){
  423. // TODO: ACTUALLY SHOW A SAD PANDA.
  424. alert('Sad Panda - No localStorage for you!')
  425. }
  426. /**
  427. * Toggles the autosave feature.
  428. *
  429. * @return {Void}
  430. */
  431. function toggleAutoSave(){
  432. $autosave.html( profile.autosave.enabled ? '<i class="icon-remove"></i>&nbsp;Disable Autosave' : '<i class="icon-ok"></i>&nbsp;Enable Autosave' );
  433. updateUserProfile({autosave: {enabled: !profile.autosave.enabled }});
  434. autoSave();
  435. }
  436. /**
  437. * Bind keyup handler to the editor.
  438. *
  439. * @return {Void}
  440. */
  441. function bindPreview(){
  442. $('#editor').bind('keyup', previewMd);
  443. }
  444. /**
  445. * Bind navigation elements.
  446. *
  447. * @return {Void}
  448. */
  449. function bindNav(){
  450. $theme
  451. .find('li > a')
  452. .bind('click', function(e){
  453. changeTheme(e);
  454. return false;
  455. });
  456. $('#clear')
  457. .on('click', function(){
  458. clearSelection();
  459. return false;
  460. });
  461. $("#autosave")
  462. .on('click', function(){
  463. toggleAutoSave();
  464. return false;
  465. });
  466. $('#reset')
  467. .on('click', function(){
  468. resetProfile();
  469. return false;
  470. });
  471. $('#cheat').
  472. on('click', function(){
  473. window.open("https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet", "_blank");
  474. return false;
  475. });
  476. } // end bindNav()
  477. /**
  478. * Bind special keyboard handlers.
  479. *
  480. * @return {Void}
  481. */
  482. function bindKeyboard(){
  483. // CMD+s TO SAVE DOC
  484. key('command+s, ctrl+s', function(e){
  485. saveFile(true);
  486. e.preventDefault(); // so we don't save the webpage - native browser functionality
  487. });
  488. var saveCommand = {
  489. name: "save",
  490. bindKey: {
  491. mac: "Command-S",
  492. win: "Ctrl-S"
  493. },
  494. exec: function(){
  495. saveFile(true);
  496. }
  497. };
  498. editor.commands.addCommand(saveCommand);
  499. }
  500. init();
  501. });
  502. /**
  503. * Get scrollHeight of preview div
  504. * (code adapted from https://github.com/anru/rsted/blob/master/static/scripts/editor.js)
  505. *
  506. * @param {Object} The jQuery object for the preview div
  507. * @return {Int} The scrollHeight of the preview area (in pixels)
  508. */
  509. function getScrollHeight($prevFrame) {
  510. // Different browsers attach the scrollHeight of a document to different
  511. // elements, so handle that here.
  512. if ($prevFrame[0].scrollHeight !== undefined) {
  513. return $prevFrame[0].scrollHeight;
  514. } else if ($prevFrame.find('html')[0].scrollHeight !== undefined &&
  515. $prevFrame.find('html')[0].scrollHeight !== 0) {
  516. return $prevFrame.find('html')[0].scrollHeight;
  517. } else {
  518. return $prevFrame.find('body')[0].scrollHeight;
  519. }
  520. }
  521. /**
  522. * Scroll preview to match cursor position in editor session
  523. * (code adapted from https://github.com/anru/rsted/blob/master/static/scripts/editor.js)
  524. *
  525. * @return {Void}
  526. */
  527. function syncPreview() {
  528. var $ed = window.ace.edit('editor');
  529. var $prev = $('#preview');
  530. var editorScrollRange = ($ed.getSession().getLength());
  531. var previewScrollRange = (getScrollHeight($prev));
  532. // Find how far along the editor is (0 means it is scrolled to the top, 1
  533. // means it is at the bottom).
  534. var scrollFactor = $ed.getFirstVisibleRow() / editorScrollRange;
  535. // Set the scroll position of the preview pane to match. jQuery will
  536. // gracefully handle out-of-bounds values.
  537. $prev.scrollTop(scrollFactor * previewScrollRange);
  538. }
  539. window.onload = function(){
  540. var $loading = $('#loading');
  541. if ($.support.transition){
  542. $loading
  543. .bind( $.support.transitionEnd, function(){
  544. $('#main').removeClass('bye');
  545. $loading.remove();
  546. })
  547. .addClass('fade_slow');
  548. } else {
  549. $('#main').removeClass('bye');
  550. $loading.remove();
  551. }
  552. /**
  553. * Bind synchronization of preview div to editor scroll and change
  554. * of editor cursor position.
  555. */
  556. window.ace.edit('editor').session.on('changeScrollTop', syncPreview);
  557. window.ace.edit('editor').session.selection.on('changeCursor', syncPreview);
  558. };