/* eslint-env browser, jquery */ /* global CodeMirror, Cookies, moment, serverurl, key, Dropbox, ot, hex2rgb, Visibility, inlineAttachment */ import TurndownService from 'turndown' import { saveAs } from 'file-saver' import randomColor from 'randomcolor' import store from 'store' import hljs from 'highlight.js' import isURL from 'validator/lib/isURL' import _ from 'lodash' import wurl from 'wurl' import List from 'list.js' import Idle from '@hackmd/idle-js' import { Spinner } from 'spin.js' import { checkLoginStateChanged, setloginStateChangeEvent } from './lib/common/login' import { debug, DROPBOX_APP_KEY, noteid, noteurl, noteAlias, urlpath, version, updateNoteAliasConfig, } from './lib/config' import { autoLinkify, deduplicatedHeaderId, exportToHTML, exportToRawHTML, removeDOMEvents, finishView, generateToc, md, parseMeta, postProcess, renderFilename, renderTOC, renderTags, renderTitle, scrollToHash, smoothHashScroll, updateLastChange, updateLastChangeUser, updateOwner } from './extra' import { clearMap, setupSyncAreas, syncScrollToEdit, syncScrollToView } from './lib/syncscroll' import { writeHistory, deleteServerHistory, getHistory, saveHistory, removeHistory } from './history' import { preventXSS } from './render' import Editor from './lib/editor' import getUIElements from './lib/editor/ui-elements' import { emojifyImageDir } from './lib/editor/constants' import modeType from './lib/modeType' import appState from './lib/appState' require('../vendor/showup/showup') require('../css/index.css') require('../css/extra.css') require('../css/slide-preview.css') require('../css/site.css') require('spin.js/spin.css') require('highlight.js/styles/github-gist.css') var defaultTextHeight = 20 var viewportMargin = 20 var defaultEditorMode = 'gfm' var idleTime = 300000 // 5 mins var updateViewDebounce = 100 var cursorMenuThrottle = 50 var cursorActivityDebounce = 50 var cursorAnimatePeriod = 100 var supportContainers = ['success', 'info', 'warning', 'danger', 'spoiler'] var supportCodeModes = ['javascript', 'typescript', 'jsx', 'htmlmixed', 'htmlembedded', 'css', 'xml', 'clike', 'clojure', 'ruby', 'python', 'shell', 'php', 'sql', 'haskell', 'coffeescript', 'yaml', 'pug', 'lua', 'cmake', 'nginx', 'perl', 'sass', 'r', 'dockerfile', 'tiddlywiki', 'mediawiki', 'go', 'gherkin'].concat(hljs.listLanguages()) var supportCharts = ['sequence', 'flow', 'graphviz', 'mermaid', 'abc', 'plantuml', 'vega', 'geo', 'fretboard', 'markmap'] var supportHeaders = [ { text: '# h1', search: '#' }, { text: '## h2', search: '##' }, { text: '### h3', search: '###' }, { text: '#### h4', search: '####' }, { text: '##### h5', search: '#####' }, { text: '###### h6', search: '######' }, { text: '###### tags: `example`', search: '###### tags:' } ] const supportReferrals = [ { text: '[reference link]', search: '[]' }, { text: '[reference]: https:// "title"', search: '[]:' }, { text: '[^footnote link]', search: '[^]' }, { text: '[^footnote reference]: https:// "title"', search: '[^]:' }, { text: '^[inline footnote]', search: '^[]' }, { text: '[link text][reference]', search: '[][]' }, { text: '[link text](https:// "title")', search: '[]()' }, { text: '![image alt][reference]', search: '![][]' }, { text: '![image alt](https:// "title")', search: '![]()' }, { text: '![image alt](https:// "title" =WidthxHeight)', search: '![]()' }, { text: '[TOC]', search: '[]' } ] const supportExternals = [ { text: '{%youtube youtubeid %}', search: 'youtube' }, { text: '{%vimeo vimeoid %}', search: 'vimeo' }, { text: '{%gist gistid %}', search: 'gist' }, { text: '{%slideshare slideshareid %}', search: 'slideshare' }, { text: '{%speakerdeck speakerdeckid %}', search: 'speakerdeck' }, { text: '{%pdf pdfurl %}', search: 'pdf' } ] const supportExtraTags = [ { text: '[name tag]', search: '[]', command: function () { return '[name=' + personalInfo.name + ']' } }, { text: '[time tag]', search: '[]', command: function () { return '[time=' + moment().format('llll') + ']' } }, { text: '[my color tag]', search: '[]', command: function () { return '[color=' + personalInfo.color + ']' } }, { text: '[random color tag]', search: '[]', command: function () { var color = randomColor() return '[color=' + color + ']' } } ] const statusType = { connected: 1, online: 2, offline: 3 } // global vars window.loaded = false let needRefresh = false let isDirty = false let editShown = false let visibleXS = false let visibleSM = false let visibleMD = false let visibleLG = false const isTouchDevice = 'ontouchstart' in document.documentElement let currentStatus = statusType.offline const lastInfo = { needRestore: false, cursor: null, scroll: null, edit: { scroll: { left: null, top: null }, cursor: { line: null, ch: null }, selections: null }, view: { scroll: { left: null, top: null } }, history: null } let personalInfo = {} let onlineUsers = [] const fileTypes = { pl: 'perl', cgi: 'perl', js: 'javascript', php: 'php', sh: 'bash', rb: 'ruby', html: 'html', py: 'python' } // editor settings const textit = document.getElementById('textit') if (!textit) { throw new Error('There was no textit area!') } const editorInstance = new Editor() var editor = editorInstance.init(textit) // FIXME: global referncing in jquery-textcomplete patch window.editor = editor var inlineAttach = inlineAttachment.editors.codemirror4.attach(editor) defaultTextHeight = parseInt($('.CodeMirror').css('line-height')) // initalize ui reference const ui = getUIElements() // page actions var opts = { lines: 11, // The number of lines to draw length: 20, // The length of each line width: 2, // The line thickness radius: 30, // The radius of the inner circle corners: 0, // Corner roundness (0..1) rotate: 0, // The rotation offset direction: 1, // 1: clockwise, -1: counterclockwise color: '#000', // #rgb or #rrggbb or array of colors speed: 1.1, // Rounds per second trail: 60, // Afterglow percentage shadow: false, // Whether to render a shadow hwaccel: true, // Whether to use hardware acceleration className: 'spinner', // The CSS class to assign to the spinner zIndex: 2e9, // The z-index (defaults to 2000000000) top: '50%', // Top position relative to parent left: '50%' // Left position relative to parent } new Spinner(opts).spin(ui.spinner[0]) // idle var idle = new Idle({ onAway: function () { idle.isAway = true emitUserStatus() updateOnlineStatus() }, onAwayBack: function () { idle.isAway = false emitUserStatus() updateOnlineStatus() setHaveUnreadChanges(false) updateTitleReminder() }, awayTimeout: idleTime }) ui.area.codemirror.on('touchstart', function () { idle.onActive() }) var haveUnreadChanges = false function setHaveUnreadChanges (bool) { if (!window.loaded) return if (bool && (idle.isAway || Visibility.hidden())) { haveUnreadChanges = true } else if (!bool && !idle.isAway && !Visibility.hidden()) { haveUnreadChanges = false } } function updateTitleReminder () { if (!window.loaded) return if (haveUnreadChanges) { document.title = '• ' + renderTitle(ui.area.markdown) } else { document.title = renderTitle(ui.area.markdown) } } function setRefreshModal (status) { $('#refreshModal').modal('show') $('#refreshModal').find('.modal-body > div').hide() $('#refreshModal').find('.' + status).show() } function setNeedRefresh () { needRefresh = true editor.setOption('readOnly', true) socket.disconnect() showStatus(statusType.offline) } setloginStateChangeEvent(function () { setRefreshModal('user-state-changed') setNeedRefresh() }) // visibility var wasFocus = false Visibility.change(function (e, state) { var hidden = Visibility.hidden() if (hidden) { if (editorHasFocus()) { wasFocus = true editor.getInputField().blur() } } else { if (wasFocus) { if (!visibleXS) { editor.focus() editor.refresh() } wasFocus = false } setHaveUnreadChanges(false) } updateTitleReminder() }) // when page ready $(document).ready(function () { idle.checkAway() checkResponsive() // if in smaller screen, we don't need advanced scrollbar var scrollbarStyle if (visibleXS) { scrollbarStyle = 'native' } else { scrollbarStyle = 'overlay' } if (scrollbarStyle !== editor.getOption('scrollbarStyle')) { editor.setOption('scrollbarStyle', scrollbarStyle) clearMap() } checkEditorStyle() /* cache dom references */ var $body = $('body') /* we need this only on touch devices */ if (isTouchDevice) { /* bind events */ $(document) .on('focus', 'textarea, input', function () { $body.addClass('fixfixed') }) .on('blur', 'textarea, input', function () { $body.removeClass('fixfixed') }) } // Re-enable nightmode if (store.get('nightMode') || Cookies.get('nightMode')) { $body.addClass('night') ui.toolbar.night.addClass('active') } // showup $().showUp('.navbar', { upClass: 'navbar-hide', downClass: 'navbar-show' }) // tooltip $('[data-toggle="tooltip"]').tooltip() // shortcuts // allow on all tags key.filter = function (e) { return true } key('ctrl+alt+e', function (e) { changeMode(modeType.edit) }) key('ctrl+alt+v', function (e) { changeMode(modeType.view) }) key('ctrl+alt+b', function (e) { changeMode(modeType.both) }) // toggle-dropdown $(document).on('click', '.toggle-dropdown .dropdown-menu', function (e) { e.stopPropagation() }) }) // when page resize $(window).resize(function () { checkLayout() checkEditorStyle() checkTocStyle() checkCursorMenu() windowResize() }) // when page unload $(window).on('unload', function () { // updateHistoryInner(); }) $(window).on('error', function () { // setNeedRefresh(); }) setupSyncAreas(ui.area.codemirrorScroll, ui.area.view, ui.area.markdown, editor) function autoSyncscroll () { if (editorHasFocus()) { syncScrollToView() } else { syncScrollToEdit() } } var windowResizeDebounce = 200 var windowResize = _.debounce(windowResizeInner, windowResizeDebounce) function windowResizeInner (callback) { checkLayout() checkResponsive() checkEditorStyle() checkTocStyle() checkCursorMenu() // refresh editor if (window.loaded) { if (editor.getOption('scrollbarStyle') === 'native') { setTimeout(function () { clearMap() autoSyncscroll() updateScrollspy() if (callback && typeof callback === 'function') { callback() } }, 1) } else { // force it load all docs at once to prevent scroll knob blink editor.setOption('viewportMargin', Infinity) setTimeout(function () { clearMap() autoSyncscroll() editor.setOption('viewportMargin', viewportMargin) // add or update user cursors for (var i = 0; i < onlineUsers.length; i++) { if (onlineUsers[i].id !== personalInfo.id) { buildCursor(onlineUsers[i]) } } updateScrollspy() if (callback && typeof callback === 'function') { callback() } }, 1) } } } function checkLayout () { var navbarHieght = $('.navbar').outerHeight() $('body').css('padding-top', navbarHieght + 'px') } function editorHasFocus () { return $(editor.getInputField()).is(':focus') } // 768-792px have a gap function checkResponsive () { visibleXS = $('.visible-xs').is(':visible') visibleSM = $('.visible-sm').is(':visible') visibleMD = $('.visible-md').is(':visible') visibleLG = $('.visible-lg').is(':visible') if (visibleXS && appState.currentMode === modeType.both) { if (editorHasFocus()) { changeMode(modeType.edit) } else { changeMode(modeType.view) } } emitUserStatus() } var lastEditorWidth = 0 var previousFocusOnEditor = null function checkEditorStyle () { var desireHeight = editorInstance.statusBar ? (ui.area.edit.height() - editorInstance.statusBar.outerHeight()) : ui.area.edit.height() if (editorInstance.toolBar) { desireHeight = desireHeight - editorInstance.toolBar.outerHeight() } // set editor height and min height based on scrollbar style and mode var scrollbarStyle = editor.getOption('scrollbarStyle') if (scrollbarStyle === 'overlay' || appState.currentMode === modeType.both) { ui.area.codemirrorScroll.css('height', desireHeight + 'px') ui.area.codemirrorScroll.css('min-height', '') checkEditorScrollbar() } else if (scrollbarStyle === 'native') { ui.area.codemirrorScroll.css('height', '') ui.area.codemirrorScroll.css('min-height', desireHeight + 'px') } // workaround editor will have wrong doc height when editor height changed editor.setSize(null, ui.area.edit.height()) checkEditorScrollOverLines() // make editor resizable if (!ui.area.resize.handle.length) { ui.area.edit.resizable({ handles: 'e', maxWidth: $(window).width() * 0.7, minWidth: $(window).width() * 0.2, create: function (e, ui) { $(this).parent().on('resize', function (e) { e.stopPropagation() }) }, start: function (e) { editor.setOption('viewportMargin', Infinity) }, resize: function (e) { ui.area.resize.syncToggle.stop(true, true).show() checkTocStyle() }, stop: function (e) { lastEditorWidth = ui.area.edit.width() // workaround that scroll event bindings window.preventSyncScrollToView = 2 window.preventSyncScrollToEdit = true editor.setOption('viewportMargin', viewportMargin) if (editorHasFocus()) { windowResizeInner(function () { ui.area.codemirrorScroll.scroll() }) } else { windowResizeInner(function () { ui.area.view.scroll() }) } checkEditorScrollbar() } }) ui.area.resize.handle = $('.ui-resizable-handle') } if (!ui.area.resize.syncToggle.length) { ui.area.resize.syncToggle = $('') ui.area.resize.syncToggle.hover(function () { previousFocusOnEditor = editorHasFocus() }, function () { previousFocusOnEditor = null }) ui.area.resize.syncToggle.click(function () { appState.syncscroll = !appState.syncscroll checkSyncToggle() }) ui.area.resize.handle.append(ui.area.resize.syncToggle) ui.area.resize.syncToggle.hide() ui.area.resize.handle.hover(function () { ui.area.resize.syncToggle.stop(true, true).delay(200).fadeIn(100) }, function () { ui.area.resize.syncToggle.stop(true, true).delay(300).fadeOut(300) }) } } function checkSyncToggle () { if (appState.syncscroll) { if (previousFocusOnEditor) { window.preventSyncScrollToView = false syncScrollToView() } else { window.preventSyncScrollToEdit = false syncScrollToEdit() } ui.area.resize.syncToggle.find('i').removeClass('fa-unlink').addClass('fa-link') } else { ui.area.resize.syncToggle.find('i').removeClass('fa-link').addClass('fa-unlink') } } var checkEditorScrollbar = _.debounce(function () { editor.operation(checkEditorScrollbarInner) }, 50) function checkEditorScrollbarInner () { // workaround simple scroll bar knob // will get wrong position when editor height changed var scrollInfo = editor.getScrollInfo() editor.scrollTo(null, scrollInfo.top - 1) editor.scrollTo(null, scrollInfo.top) } function checkEditorScrollOverLines () { const desireHeight = parseInt(ui.area.codemirrorScroll[0].style.height) || parseInt(ui.area.codemirrorScroll[0].style.minHeight) // make editor have extra padding in the bottom (except small screen) const paddingBottom = editor.doc && editor.doc.height > defaultTextHeight ? (desireHeight - defaultTextHeight) : 0 if (parseInt(ui.area.codemirrorLines.css('padding-bottom')) !== paddingBottom) { ui.area.codemirrorLines.css('padding-bottom', paddingBottom + 'px') } } function checkTocStyle () { // toc right var paddingRight = parseFloat(ui.area.markdown.css('padding-right')) var right = ($(window).width() - (ui.area.markdown.offset().left + ui.area.markdown.outerWidth() - paddingRight)) ui.toc.toc.css('right', right + 'px') // affix toc left var newbool var rightMargin = (ui.area.markdown.parent().outerWidth() - ui.area.markdown.outerWidth()) / 2 // for ipad or wider device if (rightMargin >= 133) { newbool = true var affixLeftMargin = (ui.toc.affix.outerWidth() - ui.toc.affix.width()) / 2 var left = ui.area.markdown.offset().left + ui.area.markdown.outerWidth() - affixLeftMargin ui.toc.affix.css('left', left + 'px') ui.toc.affix.css('width', rightMargin + 'px') } else { newbool = false } // toc scrollspy ui.toc.toc.removeClass('scrollspy-body, scrollspy-view') ui.toc.affix.removeClass('scrollspy-body, scrollspy-view') if (appState.currentMode === modeType.both) { ui.toc.toc.addClass('scrollspy-view') ui.toc.affix.addClass('scrollspy-view') } else if (appState.currentMode !== modeType.both && !newbool) { ui.toc.toc.addClass('scrollspy-body') ui.toc.affix.addClass('scrollspy-body') } else { ui.toc.toc.addClass('scrollspy-view') ui.toc.affix.addClass('scrollspy-body') } if (newbool !== enoughForAffixToc) { enoughForAffixToc = newbool generateScrollspy() } } function showStatus (type, num) { currentStatus = type ui.toolbar.statusConnected.hide() ui.toolbar.statusOnline.hide() ui.toolbar.statusOffline.hide() switch (currentStatus) { case statusType.connected: ui.toolbar.statusConnected.show() break case statusType.online: ui.toolbar.statusShortMsg.text(num) ui.toolbar.statusOnline.show() break case statusType.offline: ui.toolbar.statusOffline.show() break } } function toggleMode () { switch (appState.currentMode) { case modeType.edit: changeMode(modeType.view) break case modeType.view: changeMode(modeType.edit) break case modeType.both: changeMode(modeType.view) break } } var lastMode = null function changeMode (type) { // lock navbar to prevent it hide after changeMode lockNavbar() saveInfo() if (type) { lastMode = appState.currentMode appState.currentMode = type } var responsiveClass = 'col-lg-6 col-md-6 col-sm-6' var scrollClass = 'ui-scrollable' ui.area.codemirror.removeClass(scrollClass) ui.area.edit.removeClass(responsiveClass) ui.area.view.removeClass(scrollClass) ui.area.view.removeClass(responsiveClass) switch (appState.currentMode) { case modeType.edit: ui.area.edit.show() ui.area.view.hide() if (!editShown) { editor.refresh() editShown = true } break case modeType.view: ui.area.edit.hide() ui.area.view.show() break case modeType.both: ui.area.codemirror.addClass(scrollClass) ui.area.edit.addClass(responsiveClass).show() ui.area.view.addClass(scrollClass) ui.area.view.show() break } // save mode to url if (history.replaceState && window.loaded) history.replaceState(null, '', serverurl + '/' + noteid + '?' + appState.currentMode.name) if (appState.currentMode === modeType.view) { editor.getInputField().blur() } if (appState.currentMode === modeType.edit || appState.currentMode === modeType.both) { ui.toolbar.uploadImage.fadeIn() // add and update status bar if (!editorInstance.statusBar) { editorInstance.addStatusBar() editorInstance.updateStatusBar() } // add and update tool bar if (!editorInstance.toolBar) { editorInstance.addToolBar() } // work around foldGutter might not init properly editor.setOption('foldGutter', false) editor.setOption('foldGutter', true) } else { ui.toolbar.uploadImage.fadeOut() } if (appState.currentMode !== modeType.edit) { $(document.body).css('background-color', 'white') updateView() } else { $(document.body).css('background-color', ui.area.codemirror.css('background-color')) } // check resizable editor style if (appState.currentMode === modeType.both) { if (lastEditorWidth > 0) { ui.area.edit.css('width', lastEditorWidth + 'px') } else { ui.area.edit.css('width', '') } ui.area.resize.handle.show() } else { ui.area.edit.css('width', '') ui.area.resize.handle.hide() } windowResizeInner() restoreInfo() if (lastMode === modeType.view && appState.currentMode === modeType.both) { window.preventSyncScrollToView = 2 syncScrollToEdit(null, true) } if (lastMode === modeType.edit && appState.currentMode === modeType.both) { window.preventSyncScrollToEdit = 2 syncScrollToView(null, true) } if (lastMode === modeType.both && appState.currentMode !== modeType.both) { window.preventSyncScrollToView = false window.preventSyncScrollToEdit = false } if (lastMode !== modeType.edit && appState.currentMode === modeType.edit) { editor.refresh() } $(document.body).scrollspy('refresh') ui.area.view.scrollspy('refresh') ui.toolbar.both.removeClass('active') ui.toolbar.edit.removeClass('active') ui.toolbar.view.removeClass('active') var modeIcon = ui.toolbar.mode.find('i') modeIcon.removeClass('fa-pencil').removeClass('fa-eye') if (ui.area.edit.is(':visible') && ui.area.view.is(':visible')) { // both ui.toolbar.both.addClass('active') modeIcon.addClass('fa-eye') } else if (ui.area.edit.is(':visible')) { // edit ui.toolbar.edit.addClass('active') modeIcon.addClass('fa-eye') } else if (ui.area.view.is(':visible')) { // view ui.toolbar.view.addClass('active') modeIcon.addClass('fa-pencil') } unlockNavbar() } function lockNavbar () { $('.navbar').addClass('locked') } var unlockNavbar = _.debounce(function () { $('.navbar').removeClass('locked') }, 200) function showMessageModal (title, header, href, text, success) { var modal = $('.message-modal') modal.find('.modal-title').html(title) modal.find('.modal-body h5').html(header) if (href) { modal.find('.modal-body a').attr('href', href).text(text) } else { modal.find('.modal-body a').removeAttr('href').text(text) } modal.find('.modal-footer button').removeClass('btn-default btn-success btn-danger') if (success) { modal.find('.modal-footer button').addClass('btn-success') } else { modal.find('.modal-footer button').addClass('btn-danger') } modal.modal('show') } // check if dropbox app key is set and load scripts if (DROPBOX_APP_KEY) { $('