/* eslint-env browser, jquery */ /* global CodeMirror, Cookies, moment, editor, ui, Spinner, modeType, Idle, serverurl, key, gapi, Dropbox, FilePicker ot, MediaUploader, hex2rgb, num_loaded, Visibility */ require('../vendor/showup/showup') require('../css/index.css') require('../css/extra.css') require('../css/slide-preview.css') require('../css/site.css') require('highlight.js/styles/github-gist.css') import toMarkdown from 'to-markdown' import { saveAs } from 'file-saver' import randomColor from 'randomcolor' import store from 'store' import _ from 'lodash' import List from 'list.js' import { checkLoginStateChanged, setloginStateChangeEvent } from './lib/common/login' import { debug, DROPBOX_APP_KEY, noteid, noteurl, urlpath, version } from './lib/config' import { autoLinkify, deduplicatedHeaderId, exportToHTML, exportToRawHTML, removeDOMEvents, finishView, generateToc, isValidURL, 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 modeType from './lib/modeType' import appState from './lib/appState' 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'] 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'] var supportCharts = ['sequence', 'flow', 'graphviz', 'mermaid', 'abc'] 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: { msg: 'CONNECTED', label: 'label-warning', fa: 'fa-wifi' }, online: { msg: 'ONLINE', label: 'label-primary', fa: 'fa-users' }, offline: { msg: 'OFFLINE', label: 'label-danger', fa: 'fa-plug' } } // 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 let 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 } /* eslint-disable no-unused-vars */ var spinner = new Spinner(opts).spin(ui.spinner[0]) /* eslint-enable no-unused-vars */ // 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() // 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()) // 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 = $('<button class="btn btn-lg btn-default ui-sync-toggle" title="Toggle sync scrolling"><i class="fa fa-link fa-fw"></i></button>') 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 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 var shortStatus = ui.toolbar.shortStatus var status = ui.toolbar.status var label = $('<span class="label"></span>') var fa = $('<i class="fa"></i>') var msg = '' var shortMsg = '' shortStatus.html('') status.html('') switch (currentStatus) { case statusType.connected: label.addClass(statusType.connected.label) fa.addClass(statusType.connected.fa) msg = statusType.connected.msg break case statusType.online: label.addClass(statusType.online.label) fa.addClass(statusType.online.fa) shortMsg = num msg = num + ' ' + statusType.online.msg break case statusType.offline: label.addClass(statusType.offline.label) fa.addClass(statusType.offline.fa) msg = statusType.offline.msg break } label.append(fa) var shortLabel = label.clone() shortLabel.append(' ' + shortMsg) shortStatus.append(shortLabel) label.append(' ' + msg) status.append(label) } 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() } // 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) { $('<script>') .attr('type', 'text/javascript') .attr('src', 'https://www.dropbox.com/static/api/2/dropins.js') .attr('id', 'dropboxjs') .attr('data-app-key', DROPBOX_APP_KEY) .prop('async', true) .prop('defer', true) .appendTo('body') } else { ui.toolbar.import.dropbox.hide() ui.toolbar.export.dropbox.hide() } // button actions // share ui.toolbar.publish.attr('href', noteurl + '/publish') // extra // slide ui.toolbar.extra.slide.attr('href', noteurl + '/slide') // download // markdown ui.toolbar.download.markdown.click(function (e) { e.preventDefault() e.stopPropagation() var filename = renderFilename(ui.area.markdown) + '.md' var markdown = editor.getValue() var blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' }) saveAs(blob, filename, true) }) // html ui.toolbar.download.html.click(function (e) { e.preventDefault() e.stopPropagation() exportToHTML(ui.area.markdown) }) // raw html ui.toolbar.download.rawhtml.click(function (e) { e.preventDefault() e.stopPropagation() exportToRawHTML(ui.area.markdown) }) // pdf ui.toolbar.download.pdf.attr('download', '').attr('href', noteurl + '/pdf') // export to dropbox ui.toolbar.export.dropbox.click(function () { var filename = renderFilename(ui.area.markdown) + '.md' var options = { files: [ { 'url': noteurl + '/download', 'filename': filename } ], error: function (errorMessage) { console.error(errorMessage) } } Dropbox.save(options) }) // export to gist ui.toolbar.export.gist.attr('href', noteurl + '/gist') // export to snippet ui.toolbar.export.snippet.click(function () { ui.spinner.show() $.get(serverurl + '/auth/gitlab/callback/' + noteid + '/projects') .done(function (data) { $('#snippetExportModalAccessToken').val(data.accesstoken) $('#snippetExportModalBaseURL').val(data.baseURL) $('#snippetExportModalLoading').hide() $('#snippetExportModal').modal('toggle') $('#snippetExportModalProjects').find('option').remove().end().append('<option value="init" selected="selected" disabled="disabled">Select From Available Projects</option>') if (data.projects) { data.projects.sort(function (a, b) { return (a.path_with_namespace < b.path_with_namespace) ? -1 : ((a.path_with_namespace > b.path_with_namespace) ? 1 : 0) }) data.projects.forEach(function (project) { if (!project.snippets_enabled || (project.permissions.project_access === null && project.permissions.group_access === null) || (project.permissions.project_access !== null && project.permissions.project_access.access_level < 20)) { return } $('<option>').val(project.id).text(project.path_with_namespace).appendTo('#snippetExportModalProjects') }) $('#snippetExportModalProjects').prop('disabled', false) } $('#snippetExportModalLoading').hide() }) .fail(function (data) { showMessageModal('<i class="fa fa-gitlab"></i> Import from Snippet', 'Unable to fetch gitlab parameters :(', '', '', false) }) .always(function () { ui.spinner.hide() }) }) // import from dropbox ui.toolbar.import.dropbox.click(function () { var options = { success: function (files) { ui.spinner.show() var url = files[0].link importFromUrl(url) }, linkType: 'direct', multiselect: false, extensions: ['.md', '.html'] } Dropbox.choose(options) }) // import from gist ui.toolbar.import.gist.click(function () { // na }) // import from snippet ui.toolbar.import.snippet.click(function () { ui.spinner.show() $.get(serverurl + '/auth/gitlab/callback/' + noteid + '/projects') .done(function (data) { $('#snippetImportModalAccessToken').val(data.accesstoken) $('#snippetImportModalBaseURL').val(data.baseURL) $('#snippetImportModalContent').prop('disabled', false) $('#snippetImportModalConfirm').prop('disabled', false) $('#snippetImportModalLoading').hide() $('#snippetImportModal').modal('toggle') $('#snippetImportModalProjects').find('option').remove().end().append('<option value="init" selected="selected" disabled="disabled">Select From Available Projects</option>') if (data.projects) { data.projects.sort(function (a, b) { return (a.path_with_namespace < b.path_with_namespace) ? -1 : ((a.path_with_namespace > b.path_with_namespace) ? 1 : 0) }) data.projects.forEach(function (project) { if (!project.snippets_enabled || (project.permissions.project_access === null && project.permissions.group_access === null) || (project.permissions.project_access !== null && project.permissions.project_access.access_level < 20)) { return } $('<option>').val(project.id).text(project.path_with_namespace).appendTo('#snippetImportModalProjects') }) $('#snippetImportModalProjects').prop('disabled', false) } $('#snippetImportModalLoading').hide() }) .fail(function (data) { showMessageModal('<i class="fa fa-gitlab"></i> Import from Snippet', 'Unable to fetch gitlab parameters :(', '', '', false) }) .always(function () { ui.spinner.hide() }) }) // import from clipboard ui.toolbar.import.clipboard.click(function () { // na }) // upload image ui.toolbar.uploadImage.bind('change', function (e) { var files = e.target.files || e.dataTransfer.files e.dataTransfer = {} e.dataTransfer.files = files inlineAttach.onDrop(e) }) // toc ui.toc.dropdown.click(function (e) { e.stopPropagation() }) // prevent empty link change hash $('a[href="#"]').click(function (e) { e.preventDefault() }) // modal actions var revisions = [] var revisionViewer = null var revisionInsert = [] var revisionDelete = [] var revisionInsertAnnotation = null var revisionDeleteAnnotation = null var revisionList = ui.modal.revision.find('.ui-revision-list') var revision = null var revisionTime = null ui.modal.revision.on('show.bs.modal', function (e) { $.get(noteurl + '/revision') .done(function (data) { parseRevisions(data.revision) initRevisionViewer() }) .fail(function (err) { if (debug) { console.log(err) } }) .always(function () { // na }) }) function checkRevisionViewer () { if (revisionViewer) { var container = $(revisionViewer.display.wrapper).parent() $(revisionViewer.display.scroller).css('height', container.height() + 'px') revisionViewer.refresh() } } ui.modal.revision.on('shown.bs.modal', checkRevisionViewer) $(window).resize(checkRevisionViewer) function parseRevisions (_revisions) { if (_revisions.length !== revisions) { revisions = _revisions var lastRevision = null if (revisionList.children().length > 0) { lastRevision = revisionList.find('.active').attr('data-revision-time') } revisionList.html('') for (var i = 0; i < revisions.length; i++) { var revision = revisions[i] var item = $('<a href="#" class="list-group-item"></a>') item.attr('data-revision-time', revision.time) if (lastRevision === revision.time) item.addClass('active') var itemHeading = $('<h5 class="list-group-item-heading"></h5>') itemHeading.html('<i class="fa fa-clock-o"></i> ' + moment(revision.time).format('llll')) var itemText = $('<p class="list-group-item-text"></p>') itemText.html('<i class="fa fa-file-text"></i> Length: ' + revision.length) item.append(itemHeading).append(itemText) item.click(function (e) { var time = $(this).attr('data-revision-time') selectRevision(time) }) revisionList.append(item) } if (!lastRevision) { selectRevision(revisions[0].time) } } } function selectRevision (time) { if (time === revisionTime) return $.get(noteurl + '/revision/' + time) .done(function (data) { revision = data revisionTime = time var lastScrollInfo = revisionViewer.getScrollInfo() revisionList.children().removeClass('active') revisionList.find('[data-revision-time="' + time + '"]').addClass('active') var content = revision.content revisionViewer.setValue(content) revisionViewer.scrollTo(null, lastScrollInfo.top) revisionInsert = [] revisionDelete = [] // mark the text which have been insert or delete if (revision.patch.length > 0) { var bias = 0 for (var j = 0; j < revision.patch.length; j++) { var patch = revision.patch[j] var currIndex = patch.start1 + bias for (var i = 0; i < patch.diffs.length; i++) { var diff = patch.diffs[i] // ignore if diff only contains line breaks if ((diff[1].match(/\n/g) || []).length === diff[1].length) continue var prePos var postPos switch (diff[0]) { case 0: // retain currIndex += diff[1].length break case 1: // insert prePos = revisionViewer.posFromIndex(currIndex) postPos = revisionViewer.posFromIndex(currIndex + diff[1].length) revisionInsert.push({ from: prePos, to: postPos }) revisionViewer.markText(prePos, postPos, { css: 'background-color: rgba(230,255,230,0.7); text-decoration: underline;' }) currIndex += diff[1].length break case -1: // delete prePos = revisionViewer.posFromIndex(currIndex) revisionViewer.replaceRange(diff[1], prePos) postPos = revisionViewer.posFromIndex(currIndex + diff[1].length) revisionDelete.push({ from: prePos, to: postPos }) revisionViewer.markText(prePos, postPos, { css: 'background-color: rgba(255,230,230,0.7); text-decoration: line-through;' }) bias += diff[1].length currIndex += diff[1].length break } } } } revisionInsertAnnotation.update(revisionInsert) revisionDeleteAnnotation.update(revisionDelete) }) .fail(function (err) { if (debug) { console.log(err) } }) .always(function () { // na }) } function initRevisionViewer () { if (revisionViewer) return var revisionViewerTextArea = document.getElementById('revisionViewer') revisionViewer = CodeMirror.fromTextArea(revisionViewerTextArea, { mode: defaultEditorMode, viewportMargin: viewportMargin, lineNumbers: true, lineWrapping: true, showCursorWhenSelecting: true, inputStyle: 'textarea', gutters: ['CodeMirror-linenumbers'], flattenSpans: true, addModeClass: true, readOnly: true, autoRefresh: true, scrollbarStyle: 'overlay' }) revisionInsertAnnotation = revisionViewer.annotateScrollbar({ className: 'CodeMirror-insert-match' }) revisionDeleteAnnotation = revisionViewer.annotateScrollbar({ className: 'CodeMirror-delete-match' }) checkRevisionViewer() } $('#revisionModalDownload').click(function () { if (!revision) return var filename = renderFilename(ui.area.markdown) + '_' + revisionTime + '.md' var blob = new Blob([revision.content], { type: 'text/markdown;charset=utf-8' }) saveAs(blob, filename, true) }) $('#revisionModalRevert').click(function () { if (!revision) return editor.setValue(revision.content) ui.modal.revision.modal('hide') }) // snippet projects ui.modal.snippetImportProjects.change(function () { var accesstoken = $('#snippetImportModalAccessToken').val() var baseURL = $('#snippetImportModalBaseURL').val() var project = $('#snippetImportModalProjects').val() $('#snippetImportModalLoading').show() $('#snippetImportModalContent').val('/projects/' + project) $.get(baseURL + '/api/v3/projects/' + project + '/snippets?access_token=' + accesstoken) .done(function (data) { $('#snippetImportModalSnippets').find('option').remove().end().append('<option value="init" selected="selected" disabled="disabled">Select From Available Snippets</option>') data.forEach(function (snippet) { $('<option>').val(snippet.id).text(snippet.title).appendTo($('#snippetImportModalSnippets')) }) $('#snippetImportModalLoading').hide() $('#snippetImportModalSnippets').prop('disabled', false) }) .fail(function (err) { if (debug) { console.log(err) } }) .always(function () { // na }) }) // snippet snippets ui.modal.snippetImportSnippets.change(function () { var snippet = $('#snippetImportModalSnippets').val() $('#snippetImportModalContent').val($('#snippetImportModalContent').val() + '/snippets/' + snippet) }) function scrollToTop () { if (appState.currentMode === modeType.both) { if (editor.getScrollInfo().top !== 0) { editor.scrollTo(0, 0) } else { ui.area.view.animate({ scrollTop: 0 }, 100, 'linear') } } else { $('body, html').stop(true, true).animate({ scrollTop: 0 }, 100, 'linear') } } function scrollToBottom () { if (appState.currentMode === modeType.both) { var scrollInfo = editor.getScrollInfo() var scrollHeight = scrollInfo.height if (scrollInfo.top !== scrollHeight) { editor.scrollTo(0, scrollHeight * 2) } else { ui.area.view.animate({ scrollTop: ui.area.view[0].scrollHeight }, 100, 'linear') } } else { $('body, html').stop(true, true).animate({ scrollTop: $(document.body)[0].scrollHeight }, 100, 'linear') } } window.scrollToTop = scrollToTop window.scrollToBottom = scrollToBottom var enoughForAffixToc = true // scrollspy function generateScrollspy () { $(document.body).scrollspy({ target: '.scrollspy-body' }) ui.area.view.scrollspy({ target: '.scrollspy-view' }) $(document.body).scrollspy('refresh') ui.area.view.scrollspy('refresh') if (enoughForAffixToc) { ui.toc.toc.hide() ui.toc.affix.show() } else { ui.toc.affix.hide() ui.toc.toc.show() } // $(document.body).scroll(); // ui.area.view.scroll(); } function updateScrollspy () { var headers = ui.area.markdown.find('h1, h2, h3').toArray() var headerMap = [] for (var i = 0; i < headers.length; i++) { headerMap.push($(headers[i]).offset().top - parseInt($(headers[i]).css('margin-top'))) } applyScrollspyActive($(window).scrollTop(), headerMap, headers, $('.scrollspy-body'), 0) var offset = ui.area.view.scrollTop() - ui.area.view.offset().top applyScrollspyActive(ui.area.view.scrollTop(), headerMap, headers, $('.scrollspy-view'), offset - 10) } function applyScrollspyActive (top, headerMap, headers, target, offset) { var index = 0 for (var i = headerMap.length - 1; i >= 0; i--) { if (top >= (headerMap[i] + offset) && headerMap[i + 1] && top < (headerMap[i + 1] + offset)) { index = i break } } var header = $(headers[index]) var active = target.find('a[href="#' + header.attr('id') + '"]') active.closest('li').addClass('active').parent().closest('li').addClass('active').parent().closest('li').addClass('active') } // clipboard modal // fix for wrong autofocus $('#clipboardModal').on('shown.bs.modal', function () { $('#clipboardModal').blur() }) $('#clipboardModalClear').click(function () { $('#clipboardModalContent').html('') }) $('#clipboardModalConfirm').click(function () { var data = $('#clipboardModalContent').html() if (data) { parseToEditor(data) $('#clipboardModal').modal('hide') $('#clipboardModalContent').html('') } }) // refresh modal $('#refreshModalRefresh').click(function () { location.reload(true) }) // gist import modal $('#gistImportModalClear').click(function () { $('#gistImportModalContent').val('') }) $('#gistImportModalConfirm').click(function () { var gisturl = $('#gistImportModalContent').val() if (!gisturl) return $('#gistImportModal').modal('hide') $('#gistImportModalContent').val('') if (!isValidURL(gisturl)) { showMessageModal('<i class="fa fa-github"></i> Import from Gist', 'Not a valid URL :(', '', '', false) } else { var hostname = window.url('hostname', gisturl) if (hostname !== 'gist.github.com') { showMessageModal('<i class="fa fa-github"></i> Import from Gist', 'Not a valid Gist URL :(', '', '', false) } else { ui.spinner.show() $.get('https://api.github.com/gists/' + window.url('-1', gisturl)) .done(function (data) { if (data.files) { var contents = '' Object.keys(data.files).forEach(function (key) { contents += key contents += '\n---\n' contents += data.files[key].content contents += '\n\n' }) replaceAll(contents) } else { showMessageModal('<i class="fa fa-github"></i> Import from Gist', 'Unable to fetch gist files :(', '', '', false) } }) .fail(function (data) { showMessageModal('<i class="fa fa-github"></i> Import from Gist', 'Not a valid Gist URL :(', '', JSON.stringify(data), false) }) .always(function () { ui.spinner.hide() }) } } }) // snippet import modal $('#snippetImportModalClear').click(function () { $('#snippetImportModalContent').val('') $('#snippetImportModalProjects').val('init') $('#snippetImportModalSnippets').val('init') $('#snippetImportModalSnippets').prop('disabled', true) }) $('#snippetImportModalConfirm').click(function () { var snippeturl = $('#snippetImportModalContent').val() if (!snippeturl) return $('#snippetImportModal').modal('hide') $('#snippetImportModalContent').val('') if (!/^.+\/snippets\/.+$/.test(snippeturl)) { showMessageModal('<i class="fa fa-github"></i> Import from Snippet', 'Not a valid Snippet URL :(', '', '', false) } else { ui.spinner.show() var accessToken = '?access_token=' + $('#snippetImportModalAccessToken').val() var fullURL = $('#snippetImportModalBaseURL').val() + '/api/v3' + snippeturl $.get(fullURL + accessToken) .done(function (data) { var content = '# ' + (data.title || 'Snippet Import') var fileInfo = data.file_name.split('.') fileInfo[1] = (fileInfo[1]) ? fileInfo[1] : 'md' $.get(fullURL + '/raw' + accessToken) .done(function (raw) { if (raw) { content += '\n\n' if (fileInfo[1] !== 'md') { content += '```' + fileTypes[fileInfo[1]] + '\n' } content += raw if (fileInfo[1] !== 'md') { content += '\n```' } replaceAll(content) } }) .fail(function (data) { showMessageModal('<i class="fa fa-gitlab"></i> Import from Snippet', 'Not a valid Snippet URL :(', '', JSON.stringify(data), false) }) .always(function () { ui.spinner.hide() }) }) .fail(function (data) { showMessageModal('<i class="fa fa-gitlab"></i> Import from Snippet', 'Not a valid Snippet URL :(', '', JSON.stringify(data), false) }) } }) // snippet export modal $('#snippetExportModalConfirm').click(function () { var accesstoken = $('#snippetExportModalAccessToken').val() var baseURL = $('#snippetExportModalBaseURL').val() var data = { title: $('#snippetExportModalTitle').val(), file_name: $('#snippetExportModalFileName').val(), code: editor.getValue(), visibility_level: $('#snippetExportModalVisibility').val() } if (!data.title || !data.file_name || !data.code || !data.visibility_level || !$('#snippetExportModalProjects').val()) return $('#snippetExportModalLoading').show() var fullURL = baseURL + '/api/v3/projects/' + $('#snippetExportModalProjects').val() + '/snippets?access_token=' + accesstoken $.post(fullURL , data , function (ret) { $('#snippetExportModalLoading').hide() $('#snippetExportModal').modal('hide') var redirect = baseURL + '/' + $("#snippetExportModalProjects option[value='" + $('#snippetExportModalProjects').val() + "']").text() + '/snippets/' + ret.id showMessageModal('<i class="fa fa-gitlab"></i> Export to Snippet', 'Export Successful!', redirect, 'View Snippet Here', true) } , 'json' ) }) function parseToEditor (data) { var parsed = toMarkdown(data) if (parsed) { replaceAll(parsed) } } function replaceAll (data) { editor.replaceRange(data, { line: 0, ch: 0 }, { line: editor.lastLine(), ch: editor.lastLine().length }, '+input') } function importFromUrl (url) { // console.log(url); if (!url) return if (!isValidURL(url)) { showMessageModal('<i class="fa fa-cloud-download"></i> Import from URL', 'Not a valid URL :(', '', '', false) return } $.ajax({ method: 'GET', url: url, success: function (data) { var extension = url.split('.').pop() if (extension === 'html') { parseToEditor(data) } else { replaceAll(data) } }, error: function (data) { showMessageModal('<i class="fa fa-cloud-download"></i> Import from URL', 'Import failed :(', '', JSON.stringify(data), false) }, complete: function () { ui.spinner.hide() } }) } // mode ui.toolbar.mode.click(function () { toggleMode() }) // edit ui.toolbar.edit.click(function () { changeMode(modeType.edit) }) // view ui.toolbar.view.click(function () { changeMode(modeType.view) }) // both ui.toolbar.both.click(function () { changeMode(modeType.both) }) ui.toolbar.night.click(function () { toggleNightMode() }) // permission // freely ui.infobar.permission.freely.click(function () { emitPermission('freely') }) // editable ui.infobar.permission.editable.click(function () { emitPermission('editable') }) // locked ui.infobar.permission.locked.click(function () { emitPermission('locked') }) // private ui.infobar.permission.private.click(function () { emitPermission('private') }) // limited ui.infobar.permission.limited.click(function () { emitPermission('limited') }) // protected ui.infobar.permission.protected.click(function () { emitPermission('protected') }) // delete note ui.infobar.delete.click(function () { $('.delete-modal').modal('show') }) $('.ui-delete-modal-confirm').click(function () { socket.emit('delete') }) function toggleNightMode () { var $body = $('body') var isActive = ui.toolbar.night.hasClass('active') if (isActive) { $body.removeClass('night') appState.nightMode = false } else { $body.addClass('night') appState.nightMode = true } if (store.enabled) { store.set('nightMode', !isActive) } else { Cookies.set('nightMode', !isActive, { expires: 365 }) } } function emitPermission (_permission) { if (_permission !== permission) { socket.emit('permission', _permission) } } function updatePermission (newPermission) { if (permission !== newPermission) { permission = newPermission if (window.loaded) refreshView() } var label = null var title = null switch (permission) { case 'freely': label = '<i class="fa fa-leaf"></i> Freely' title = 'Anyone can edit' break case 'editable': label = '<i class="fa fa-shield"></i> Editable' title = 'Signed people can edit' break case 'limited': label = '<i class="fa fa-id-card"></i> Limited' title = 'Signed people can edit (forbid guest)' break case 'locked': label = '<i class="fa fa-lock"></i> Locked' title = 'Only owner can edit' break case 'protected': label = '<i class="fa fa-umbrella"></i> Protected' title = 'Only owner can edit (forbid guest)' break case 'private': label = '<i class="fa fa-hand-stop-o"></i> Private' title = 'Only owner can view & edit' break } if (personalInfo.userid && window.owner && personalInfo.userid === window.owner) { label += ' <i class="fa fa-caret-down"></i>' ui.infobar.permission.label.removeClass('disabled') } else { ui.infobar.permission.label.addClass('disabled') } ui.infobar.permission.label.html(label).attr('title', title) } function havePermission () { var bool = false switch (permission) { case 'freely': bool = true break case 'editable': case 'limited': if (!personalInfo.login) { bool = false } else { bool = true } break case 'locked': case 'private': case 'protected': if (!window.owner || personalInfo.userid !== window.owner) { bool = false } else { bool = true } break } return bool } // global module workaround window.havePermission = havePermission // socket.io actions var io = require('socket.io-client') var socket = io.connect({ path: urlpath ? '/' + urlpath + '/socket.io/' : '', query: { noteId: noteid }, timeout: 5000, // 5 secs to timeout, reconnectionAttempts: 20 // retry 20 times on connect failed }) // overwrite original event for checking login state var on = socket.on socket.on = function () { if (!checkLoginStateChanged() && !needRefresh) { return on.apply(socket, arguments) } } var emit = socket.emit socket.emit = function () { if (!checkLoginStateChanged() && !needRefresh) { emit.apply(socket, arguments) } } socket.on('info', function (data) { console.error(data) switch (data.code) { case 403: location.href = serverurl + '/403' break case 404: location.href = serverurl + '/404' break case 500: location.href = serverurl + '/500' break } }) socket.on('error', function (data) { console.error(data) if (data.message && data.message.indexOf('AUTH failed') === 0) { location.href = serverurl + '/403' } }) socket.on('delete', function () { if (personalInfo.login) { deleteServerHistory(noteid, function (err, data) { if (!err) location.href = serverurl }) } else { getHistory(function (notehistory) { var newnotehistory = removeHistory(noteid, notehistory) saveHistory(newnotehistory) location.href = serverurl }) } }) var retryTimer = null socket.on('maintenance', function () { cmClient.revision = -1 }) socket.on('disconnect', function (data) { showStatus(statusType.offline) if (window.loaded) { saveInfo() lastInfo.history = editor.getHistory() } if (!editor.getOption('readOnly')) { editor.setOption('readOnly', true) } if (!retryTimer) { retryTimer = setInterval(function () { if (!needRefresh) socket.connect() }, 1000) } }) socket.on('reconnect', function (data) { // sync back any change in offline emitUserStatus(true) cursorActivity(editor) socket.emit('online users') }) socket.on('connect', function (data) { clearInterval(retryTimer) retryTimer = null personalInfo['id'] = socket.id showStatus(statusType.connected) socket.emit('version') }) socket.on('version', function (data) { if (version !== data.version) { if (version < data.minimumCompatibleVersion) { setRefreshModal('incompatible-version') setNeedRefresh() } else { setRefreshModal('new-version') } } }) var authors = [] var authorship = [] var authorMarks = {} // temp variable var addTextMarkers = [] // temp variable function updateInfo (data) { // console.log(data); if (data.hasOwnProperty('createtime') && window.createtime !== data.createtime) { window.createtime = data.createtime updateLastChange() } if (data.hasOwnProperty('updatetime') && window.lastchangetime !== data.updatetime) { window.lastchangetime = data.updatetime updateLastChange() } if (data.hasOwnProperty('owner') && window.owner !== data.owner) { window.owner = data.owner window.ownerprofile = data.ownerprofile updateOwner() } if (data.hasOwnProperty('lastchangeuser') && window.lastchangeuser !== data.lastchangeuser) { window.lastchangeuser = data.lastchangeuser window.lastchangeuserprofile = data.lastchangeuserprofile updateLastChangeUser() updateOwner() } if (data.hasOwnProperty('authors') && authors !== data.authors) { authors = data.authors } if (data.hasOwnProperty('authorship') && authorship !== data.authorship) { authorship = data.authorship updateAuthorship() } } var updateAuthorship = _.debounce(function () { editor.operation(updateAuthorshipInner) }, 50) function initMark () { return { gutter: { userid: null, timestamp: null }, textmarkers: [] } } function initMarkAndCheckGutter (mark, author, timestamp) { if (!mark) mark = initMark() if (!mark.gutter.userid || mark.gutter.timestamp > timestamp) { mark.gutter.userid = author.userid mark.gutter.timestamp = timestamp } return mark } var addStyleRule = (function () { var added = {} var styleElement = document.createElement('style') document.documentElement.getElementsByTagName('head')[0].appendChild(styleElement) var styleSheet = styleElement.sheet return function (css) { if (added[css]) { return } added[css] = true styleSheet.insertRule(css, (styleSheet.cssRules || styleSheet.rules).length) } }()) function updateAuthorshipInner () { // ignore when ot not synced yet if (havePendingOperation()) return authorMarks = {} for (let i = 0; i < authorship.length; i++) { var atom = authorship[i] let author = authors[atom[0]] if (author) { var prePos = editor.posFromIndex(atom[1]) var preLine = editor.getLine(prePos.line) var postPos = editor.posFromIndex(atom[2]) var postLine = editor.getLine(postPos.line) if (prePos.ch === 0 && postPos.ch === postLine.length) { for (let j = prePos.line; j <= postPos.line; j++) { if (editor.getLine(j)) { authorMarks[j] = initMarkAndCheckGutter(authorMarks[j], author, atom[3]) } } } else if (postPos.line - prePos.line >= 1) { var startLine = prePos.line var endLine = postPos.line if (prePos.ch === preLine.length) { startLine++ } else if (prePos.ch !== 0) { let mark = initMarkAndCheckGutter(authorMarks[prePos.line], author, atom[3]) var _postPos = { line: prePos.line, ch: preLine.length } if (JSON.stringify(prePos) !== JSON.stringify(_postPos)) { mark.textmarkers.push({ userid: author.userid, pos: [prePos, _postPos] }) startLine++ } authorMarks[prePos.line] = mark } if (postPos.ch === 0) { endLine-- } else if (postPos.ch !== postLine.length) { let mark = initMarkAndCheckGutter(authorMarks[postPos.line], author, atom[3]) var _prePos = { line: postPos.line, ch: 0 } if (JSON.stringify(_prePos) !== JSON.stringify(postPos)) { mark.textmarkers.push({ userid: author.userid, pos: [_prePos, postPos] }) endLine-- } authorMarks[postPos.line] = mark } for (let j = startLine; j <= endLine; j++) { if (editor.getLine(j)) { authorMarks[j] = initMarkAndCheckGutter(authorMarks[j], author, atom[3]) } } } else { let mark = initMarkAndCheckGutter(authorMarks[prePos.line], author, atom[3]) if (JSON.stringify(prePos) !== JSON.stringify(postPos)) { mark.textmarkers.push({ userid: author.userid, pos: [prePos, postPos] }) } authorMarks[prePos.line] = mark } } } addTextMarkers = [] editor.eachLine(iterateLine) var allTextMarks = editor.getAllMarks() for (let i = 0; i < allTextMarks.length; i++) { let _textMarker = allTextMarks[i] var pos = _textMarker.find() var found = false for (let j = 0; j < addTextMarkers.length; j++) { let textMarker = addTextMarkers[j] let author = authors[textMarker.userid] let className = 'authorship-inline-' + author.color.substr(1) var obj = { from: textMarker.pos[0], to: textMarker.pos[1] } if (JSON.stringify(pos) === JSON.stringify(obj) && _textMarker.className && _textMarker.className.indexOf(className) > -1) { addTextMarkers.splice(j, 1) j-- found = true break } } if (!found && _textMarker.className && _textMarker.className.indexOf('authorship-inline') > -1) { _textMarker.clear() } } for (let i = 0; i < addTextMarkers.length; i++) { let textMarker = addTextMarkers[i] let author = authors[textMarker.userid] const rgbcolor = hex2rgb(author.color) const colorString = `rgba(${rgbcolor.red},${rgbcolor.green},${rgbcolor.blue},0.7)` const styleString = `background-image: linear-gradient(to top, ${colorString} 1px, transparent 1px);` let className = `authorship-inline-${author.color.substr(1)}` const rule = `.${className} { ${styleString} }` addStyleRule(rule) editor.markText(textMarker.pos[0], textMarker.pos[1], { className: 'authorship-inline ' + className, title: author.name }) } } function iterateLine (line) { var lineNumber = line.lineNo() var currMark = authorMarks[lineNumber] var author = currMark ? authors[currMark.gutter.userid] : null if (currMark && author) { let className = 'authorship-gutter-' + author.color.substr(1) const gutters = line.gutterMarkers if (!gutters || !gutters['authorship-gutters'] || !gutters['authorship-gutters'].className || !gutters['authorship-gutters'].className.indexOf(className) < 0) { const styleString = `border-left: 3px solid ${author.color}; height: ${defaultTextHeight}px; margin-left: 3px;` const rule = `.${className} { ${styleString} }` addStyleRule(rule) var gutter = $('<div>', { class: 'authorship-gutter ' + className, title: author.name }) editor.setGutterMarker(line, 'authorship-gutters', gutter[0]) } } else { editor.setGutterMarker(line, 'authorship-gutters', null) } if (currMark && currMark.textmarkers.length > 0) { for (var i = 0; i < currMark.textmarkers.length; i++) { let textMarker = currMark.textmarkers[i] if (textMarker.userid !== currMark.gutter.userid) { addTextMarkers.push(textMarker) } } } } editorInstance.on('update', function () { $('.authorship-gutter:not([data-original-title])').tooltip({ container: '.CodeMirror-lines', placement: 'right', delay: { 'show': 500, 'hide': 100 } }) $('.authorship-inline:not([data-original-title])').tooltip({ container: '.CodeMirror-lines', placement: 'bottom', delay: { 'show': 500, 'hide': 100 } }) // clear tooltip which described element has been removed $('[id^="tooltip"]').each(function (index, element) { var $ele = $(element) if ($('[aria-describedby="' + $ele.attr('id') + '"]').length <= 0) $ele.remove() }) }) socket.on('check', function (data) { // console.log(data); updateInfo(data) }) socket.on('permission', function (data) { updatePermission(data.permission) }) var permission = null socket.on('refresh', function (data) { // console.log(data); editorInstance.config.docmaxlength = data.docmaxlength editor.setOption('maxLength', editorInstance.config.docmaxlength) updateInfo(data) updatePermission(data.permission) if (!window.loaded) { // auto change mode if no content detected var nocontent = editor.getValue().length <= 0 if (nocontent) { if (visibleXS) { appState.currentMode = modeType.edit } else { appState.currentMode = modeType.both } } // parse mode from url if (window.location.search.length > 0) { var urlMode = modeType[window.location.search.substr(1)] if (urlMode) appState.currentMode = urlMode } changeMode(appState.currentMode) if (nocontent && !visibleXS) { editor.focus() editor.refresh() } updateViewInner() // bring up view rendering earlier updateHistory() // update history whether have content or not window.loaded = true emitUserStatus() // send first user status updateOnlineStatus() // update first online status setTimeout(function () { // work around editor not refresh or doc not fully loaded windowResizeInner() // work around might not scroll to hash scrollToHash() }, 1) } if (editor.getOption('readOnly')) { editor.setOption('readOnly', false) } }) var EditorClient = ot.EditorClient var SocketIOAdapter = ot.SocketIOAdapter var CodeMirrorAdapter = ot.CodeMirrorAdapter var cmClient = null var synchronized_ = null function havePendingOperation () { return !!((cmClient && cmClient.state && cmClient.state.hasOwnProperty('outstanding'))) } socket.on('doc', function (obj) { var body = obj.str var bodyMismatch = editor.getValue() !== body var setDoc = !cmClient || (cmClient && (cmClient.revision === -1 || (cmClient.revision !== obj.revision && !havePendingOperation()))) || obj.force saveInfo() if (setDoc && bodyMismatch) { if (cmClient) cmClient.editorAdapter.ignoreNextChange = true if (body) editor.setValue(body) else editor.setValue('') } if (!window.loaded) { editor.clearHistory() ui.spinner.hide() ui.content.fadeIn() } else { // if current doc is equal to the doc before disconnect if (setDoc && bodyMismatch) editor.clearHistory() else if (lastInfo.history) editor.setHistory(lastInfo.history) lastInfo.history = null } if (!cmClient) { cmClient = window.cmClient = new EditorClient( obj.revision, obj.clients, new SocketIOAdapter(socket), new CodeMirrorAdapter(editor) ) synchronized_ = cmClient.state } else if (setDoc) { if (bodyMismatch) { cmClient.undoManager.undoStack.length = 0 cmClient.undoManager.redoStack.length = 0 } cmClient.revision = obj.revision cmClient.setState(synchronized_) cmClient.initializeClientList() cmClient.initializeClients(obj.clients) } else if (havePendingOperation()) { cmClient.serverReconnect() } if (setDoc && bodyMismatch) { isDirty = true updateView() } restoreInfo() }) socket.on('ack', function () { isDirty = true updateView() }) socket.on('operation', function () { isDirty = true updateView() }) socket.on('online users', function (data) { if (debug) { console.debug(data) } onlineUsers = data.users updateOnlineStatus() $('.CodeMirror-other-cursors').children().each(function (key, value) { var found = false for (var i = 0; i < data.users.length; i++) { var user = data.users[i] if ($(this).attr('id') === user.id) { found = true } } if (!found) { $(this).stop(true).fadeOut('normal', function () { $(this).remove() }) } }) for (var i = 0; i < data.users.length; i++) { var user = data.users[i] if (user.id !== socket.id) { buildCursor(user) } else { personalInfo = user } } }) socket.on('user status', function (data) { if (debug) { console.debug(data) } for (var i = 0; i < onlineUsers.length; i++) { if (onlineUsers[i].id === data.id) { onlineUsers[i] = data } } updateOnlineStatus() if (data.id !== socket.id) { buildCursor(data) } }) socket.on('cursor focus', function (data) { if (debug) { console.debug(data) } for (var i = 0; i < onlineUsers.length; i++) { if (onlineUsers[i].id === data.id) { onlineUsers[i].cursor = data.cursor } } if (data.id !== socket.id) { buildCursor(data) } // force show var cursor = $('div[data-clientid="' + data.id + '"]') if (cursor.length > 0) { cursor.stop(true).fadeIn() } }) socket.on('cursor activity', function (data) { if (debug) { console.debug(data) } for (var i = 0; i < onlineUsers.length; i++) { if (onlineUsers[i].id === data.id) { onlineUsers[i].cursor = data.cursor } } if (data.id !== socket.id) { buildCursor(data) } }) socket.on('cursor blur', function (data) { if (debug) { console.debug(data) } for (var i = 0; i < onlineUsers.length; i++) { if (onlineUsers[i].id === data.id) { onlineUsers[i].cursor = null } } if (data.id !== socket.id) { buildCursor(data) } // force hide var cursor = $('div[data-clientid="' + data.id + '"]') if (cursor.length > 0) { cursor.stop(true).fadeOut() } }) var options = { valueNames: ['id', 'name'], item: '<li class="ui-user-item">' + '<span class="id" style="display:none;"></span>' + '<a href="#">' + '<span class="pull-left"><i class="ui-user-icon"></i></span><span class="ui-user-name name"></span><span class="pull-right"><i class="fa fa-circle ui-user-status"></i></span>' + '</a>' + '</li>' } var onlineUserList = new List('online-user-list', options) var shortOnlineUserList = new List('short-online-user-list', options) function updateOnlineStatus () { if (!window.loaded || !socket.connected) return var _onlineUsers = deduplicateOnlineUsers(onlineUsers) showStatus(statusType.online, _onlineUsers.length) var items = onlineUserList.items // update or remove current list items for (let i = 0; i < items.length; i++) { let found = false let foundindex = null for (let j = 0; j < _onlineUsers.length; j++) { if (items[i].values().id === _onlineUsers[j].id) { foundindex = j found = true break } } let id = items[i].values().id if (found) { onlineUserList.get('id', id)[0].values(_onlineUsers[foundindex]) shortOnlineUserList.get('id', id)[0].values(_onlineUsers[foundindex]) } else { onlineUserList.remove('id', id) shortOnlineUserList.remove('id', id) } } // add not in list items for (let i = 0; i < _onlineUsers.length; i++) { let found = false for (let j = 0; j < items.length; j++) { if (items[j].values().id === _onlineUsers[i].id) { found = true break } } if (!found) { onlineUserList.add(_onlineUsers[i]) shortOnlineUserList.add(_onlineUsers[i]) } } // sorting sortOnlineUserList(onlineUserList) sortOnlineUserList(shortOnlineUserList) // render list items renderUserStatusList(onlineUserList) renderUserStatusList(shortOnlineUserList) } function sortOnlineUserList (list) { // sort order by isSelf, login state, idle state, alphabet name, color brightness list.sort('', { sortFunction: function (a, b) { var usera = a.values() var userb = b.values() var useraIsSelf = (usera.id === personalInfo.id || (usera.login && usera.userid === personalInfo.userid)) var userbIsSelf = (userb.id === personalInfo.id || (userb.login && userb.userid === personalInfo.userid)) if (useraIsSelf && !userbIsSelf) { return -1 } else if (!useraIsSelf && userbIsSelf) { return 1 } else { if (usera.login && !userb.login) { return -1 } else if (!usera.login && userb.login) { return 1 } else { if (!usera.idle && userb.idle) { return -1 } else if (usera.idle && !userb.idle) { return 1 } else { if (usera.name && userb.name && usera.name.toLowerCase() < userb.name.toLowerCase()) { return -1 } else if (usera.name && userb.name && usera.name.toLowerCase() > userb.name.toLowerCase()) { return 1 } else { if (usera.color && userb.color && usera.color.toLowerCase() < userb.color.toLowerCase()) { return -1 } else if (usera.color && userb.color && usera.color.toLowerCase() > userb.color.toLowerCase()) { return 1 } else { return 0 } } } } } } }) } function renderUserStatusList (list) { var items = list.items for (var j = 0; j < items.length; j++) { var item = items[j] var userstatus = $(item.elm).find('.ui-user-status') var usericon = $(item.elm).find('.ui-user-icon') if (item.values().login && item.values().photo) { usericon.css('background-image', 'url(' + item.values().photo + ')') // add 1px more to right, make it feel aligned usericon.css('margin-right', '6px') $(item.elm).css('border-left', '4px solid ' + item.values().color) usericon.css('margin-left', '-4px') } else { usericon.css('background-color', item.values().color) } userstatus.removeClass('ui-user-status-offline ui-user-status-online ui-user-status-idle') if (item.values().idle) { userstatus.addClass('ui-user-status-idle') } else { userstatus.addClass('ui-user-status-online') } } } function deduplicateOnlineUsers (list) { var _onlineUsers = [] for (var i = 0; i < list.length; i++) { var user = $.extend({}, list[i]) if (!user.userid) { _onlineUsers.push(user) } else { var found = false for (var j = 0; j < _onlineUsers.length; j++) { if (_onlineUsers[j].userid === user.userid) { // keep self color when login if (user.id === personalInfo.id) { _onlineUsers[j].color = user.color } // keep idle state if any of self client not idle if (!user.idle) { _onlineUsers[j].idle = user.idle _onlineUsers[j].color = user.color } found = true break } } if (!found) { _onlineUsers.push(user) } } } return _onlineUsers } var userStatusCache = null function emitUserStatus (force) { if (!window.loaded) return var type = null if (visibleXS) { type = 'xs' } else if (visibleSM) { type = 'sm' } else if (visibleMD) { type = 'md' } else if (visibleLG) { type = 'lg' } personalInfo['idle'] = idle.isAway personalInfo['type'] = type for (var i = 0; i < onlineUsers.length; i++) { if (onlineUsers[i].id === personalInfo.id) { onlineUsers[i] = personalInfo } } var userStatus = { idle: idle.isAway, type: type } if (force || JSON.stringify(userStatus) !== JSON.stringify(userStatusCache)) { socket.emit('user status', userStatus) userStatusCache = userStatus } } function checkCursorTag (coord, ele) { if (!ele) return // return if element not exists // set margin var tagRightMargin = 0 var tagBottomMargin = 2 // use sizer to get the real doc size (won't count status bar and gutters) var docWidth = ui.area.codemirrorSizer.width() // get editor size (status bar not count in) var editorHeight = ui.area.codemirror.height() // get element size var width = ele.outerWidth() var height = ele.outerHeight() var padding = (ele.outerWidth() - ele.width()) / 2 // get coord position var left = coord.left var top = coord.top // get doc top offset (to workaround with viewport) var docTopOffset = ui.area.codemirrorSizerInner.position().top // set offset var offsetLeft = -3 var offsetTop = defaultTextHeight // only do when have width and height if (width > 0 && height > 0) { // flip x when element right bound larger than doc width if (left + width + offsetLeft + tagRightMargin > docWidth) { offsetLeft = -(width + tagRightMargin) + padding + offsetLeft } // flip y when element bottom bound larger than doc height // and element top position is larger than element height if (top + docTopOffset + height + offsetTop + tagBottomMargin > Math.max(editor.doc.height, editorHeight) && top + docTopOffset > height + tagBottomMargin) { offsetTop = -(height) } } // set position ele[0].style.left = offsetLeft + 'px' ele[0].style.top = offsetTop + 'px' } function buildCursor (user) { if (appState.currentMode === modeType.view) return if (!user.cursor) return var coord = editor.charCoords(user.cursor, 'windows') coord.left = coord.left < 4 ? 4 : coord.left coord.top = coord.top < 0 ? 0 : coord.top var iconClass = 'fa-user' switch (user.type) { case 'xs': iconClass = 'fa-mobile' break case 'sm': iconClass = 'fa-tablet' break case 'md': iconClass = 'fa-desktop' break case 'lg': iconClass = 'fa-desktop' break } if ($('div[data-clientid="' + user.id + '"]').length <= 0) { let cursor = $('<div data-clientid="' + user.id + '" class="CodeMirror-other-cursor" style="display:none;"></div>') cursor.attr('data-line', user.cursor.line) cursor.attr('data-ch', user.cursor.ch) cursor.attr('data-offset-left', 0) cursor.attr('data-offset-top', 0) let cursorbar = $('<div class="cursorbar"> </div>') cursorbar[0].style.height = defaultTextHeight + 'px' cursorbar[0].style.borderLeft = '2px solid ' + user.color var icon = '<i class="fa ' + iconClass + '"></i>' let cursortag = $('<div class="cursortag">' + icon + ' <span class="name">' + user.name + '</span></div>') // cursortag[0].style.background = color; cursortag[0].style.color = user.color cursor.attr('data-mode', 'hover') cursortag.delay(2000).fadeOut('fast') cursor.hover( function () { if (cursor.attr('data-mode') === 'hover') { cursortag.stop(true).fadeIn('fast') } }, function () { if (cursor.attr('data-mode') === 'hover') { cursortag.stop(true).fadeOut('fast') } }) var hideCursorTagDelay = 2000 var hideCursorTagTimer = null var switchMode = function (ele) { if (ele.attr('data-mode') === 'state') { ele.attr('data-mode', 'hover') } else if (ele.attr('data-mode') === 'hover') { ele.attr('data-mode', 'state') } } var switchTag = function (ele) { if (ele.css('display') === 'none') { ele.stop(true).fadeIn('fast') } else { ele.stop(true).fadeOut('fast') } } var hideCursorTag = function () { if (cursor.attr('data-mode') === 'hover') { cursortag.fadeOut('fast') } } cursor.on('touchstart', function (e) { var display = cursortag.css('display') cursortag.stop(true).fadeIn('fast') clearTimeout(hideCursorTagTimer) hideCursorTagTimer = setTimeout(hideCursorTag, hideCursorTagDelay) if (display === 'none') { e.preventDefault() e.stopPropagation() } }) cursortag.on('mousedown touchstart', function (e) { if (cursor.attr('data-mode') === 'state') { switchTag(cursortag) } switchMode(cursor) e.preventDefault() e.stopPropagation() }) cursor.append(cursorbar) cursor.append(cursortag) cursor[0].style.left = coord.left + 'px' cursor[0].style.top = coord.top + 'px' $('.CodeMirror-other-cursors').append(cursor) if (!user.idle) { cursor.stop(true).fadeIn() } checkCursorTag(coord, cursortag) } else { let cursor = $('div[data-clientid="' + user.id + '"]') cursor.attr('data-line', user.cursor.line) cursor.attr('data-ch', user.cursor.ch) let cursorbar = cursor.find('.cursorbar') cursorbar[0].style.height = defaultTextHeight + 'px' cursorbar[0].style.borderLeft = '2px solid ' + user.color let cursortag = cursor.find('.cursortag') cursortag.find('i').removeClass().addClass('fa').addClass(iconClass) cursortag.find('.name').text(user.name) if (cursor.css('display') === 'none') { cursor[0].style.left = coord.left + 'px' cursor[0].style.top = coord.top + 'px' } else { cursor.animate({ 'left': coord.left, 'top': coord.top }, { duration: cursorAnimatePeriod, queue: false }) } if (user.idle && cursor.css('display') !== 'none') { cursor.stop(true).fadeOut() } else if (!user.idle && cursor.css('display') === 'none') { cursor.stop(true).fadeIn() } checkCursorTag(coord, cursortag) } } // editor actions function removeNullByte (cm, change) { var str = change.text.join('\n') if (/\u0000/g.test(str) && change.update) { change.update(change.from, change.to, str.replace(/\u0000/g, '').split('\n')) } } function enforceMaxLength (cm, change) { var maxLength = cm.getOption('maxLength') if (maxLength && change.update) { var str = change.text.join('\n') var delta = str.length - (cm.indexFromPos(change.to) - cm.indexFromPos(change.from)) if (delta <= 0) { return false } delta = cm.getValue().length + delta - maxLength if (delta > 0) { str = str.substr(0, str.length - delta) change.update(change.from, change.to, str.split('\n')) return true } } return false } var ignoreEmitEvents = ['setValue', 'ignoreHistory'] editorInstance.on('beforeChange', function (cm, change) { if (debug) { console.debug(change) } removeNullByte(cm, change) if (enforceMaxLength(cm, change)) { $('.limit-modal').modal('show') } var isIgnoreEmitEvent = (ignoreEmitEvents.indexOf(change.origin) !== -1) if (!isIgnoreEmitEvent) { if (!havePermission()) { change.canceled = true switch (permission) { case 'editable': $('.signin-modal').modal('show') break case 'locked': case 'private': $('.locked-modal').modal('show') break } } } else { if (change.origin === 'ignoreHistory') { setHaveUnreadChanges(true) updateTitleReminder() } } if (cmClient && !socket.connected) { cmClient.editorAdapter.ignoreNextChange = true } }) editorInstance.on('cut', function () { // na }) editorInstance.on('paste', function () { // na }) editorInstance.on('changes', function (editor, changes) { updateHistory() var docLength = editor.getValue().length // workaround for big documents var newViewportMargin = 20 if (docLength > 20000) { newViewportMargin = 1 } else if (docLength > 10000) { newViewportMargin = 10 } else if (docLength > 5000) { newViewportMargin = 15 } if (newViewportMargin !== viewportMargin) { viewportMargin = newViewportMargin windowResize() } checkEditorScrollbar() if (ui.area.codemirrorScroll[0].scrollHeight > ui.area.view[0].scrollHeight && editorHasFocus()) { postUpdateEvent = function () { syncScrollToView() postUpdateEvent = null } } }) editorInstance.on('focus', function (editor) { for (var i = 0; i < onlineUsers.length; i++) { if (onlineUsers[i].id === personalInfo.id) { onlineUsers[i].cursor = editor.getCursor() } } personalInfo['cursor'] = editor.getCursor() socket.emit('cursor focus', editor.getCursor()) }) const cursorActivity = _.debounce(cursorActivityInner, cursorActivityDebounce) function cursorActivityInner (editor) { if (editorHasFocus() && !Visibility.hidden()) { for (var i = 0; i < onlineUsers.length; i++) { if (onlineUsers[i].id === personalInfo.id) { onlineUsers[i].cursor = editor.getCursor() } } personalInfo['cursor'] = editor.getCursor() socket.emit('cursor activity', editor.getCursor()) } } editorInstance.on('cursorActivity', editorInstance.updateStatusBar) editorInstance.on('cursorActivity', cursorActivity) editorInstance.on('beforeSelectionChange', editorInstance.updateStatusBar) editorInstance.on('beforeSelectionChange', function (doc, selections) { // check selection and whether the statusbar has added if (selections && editorInstance.statusSelection) { const selection = selections.ranges[0] const anchor = selection.anchor const head = selection.head const start = head.line <= anchor.line ? head : anchor const end = head.line >= anchor.line ? head : anchor const selectionCharCount = Math.abs(head.ch - anchor.ch) let selectionText = ' — Selected ' // borrow from brackets EditorStatusBar.js if (start.line !== end.line) { var lines = end.line - start.line + 1 if (end.ch === 0) { lines-- } selectionText += lines + ' lines' } else if (selectionCharCount > 0) { selectionText += selectionCharCount + ' columns' } if (start.line !== end.line || selectionCharCount > 0) { editorInstance.statusSelection.text(selectionText) } else { editorInstance.statusSelection.text('') } } }) editorInstance.on('blur', function (cm) { for (var i = 0; i < onlineUsers.length; i++) { if (onlineUsers[i].id === personalInfo.id) { onlineUsers[i].cursor = null } } personalInfo['cursor'] = null socket.emit('cursor blur') }) function saveInfo () { var scrollbarStyle = editor.getOption('scrollbarStyle') var left = $(window).scrollLeft() var top = $(window).scrollTop() switch (appState.currentMode) { case modeType.edit: if (scrollbarStyle === 'native') { lastInfo.edit.scroll.left = left lastInfo.edit.scroll.top = top } else { lastInfo.edit.scroll = editor.getScrollInfo() } break case modeType.view: lastInfo.view.scroll.left = left lastInfo.view.scroll.top = top break case modeType.both: lastInfo.edit.scroll = editor.getScrollInfo() lastInfo.view.scroll.left = ui.area.view.scrollLeft() lastInfo.view.scroll.top = ui.area.view.scrollTop() break } lastInfo.edit.cursor = editor.getCursor() lastInfo.edit.selections = editor.listSelections() lastInfo.needRestore = true } function restoreInfo () { var scrollbarStyle = editor.getOption('scrollbarStyle') if (lastInfo.needRestore) { var line = lastInfo.edit.cursor.line var ch = lastInfo.edit.cursor.ch editor.setCursor(line, ch) editor.setSelections(lastInfo.edit.selections) switch (appState.currentMode) { case modeType.edit: if (scrollbarStyle === 'native') { $(window).scrollLeft(lastInfo.edit.scroll.left) $(window).scrollTop(lastInfo.edit.scroll.top) } else { let left = lastInfo.edit.scroll.left let top = lastInfo.edit.scroll.top editor.scrollIntoView() editor.scrollTo(left, top) } break case modeType.view: $(window).scrollLeft(lastInfo.view.scroll.left) $(window).scrollTop(lastInfo.view.scroll.top) break case modeType.both: let left = lastInfo.edit.scroll.left let top = lastInfo.edit.scroll.top editor.scrollIntoView() editor.scrollTo(left, top) ui.area.view.scrollLeft(lastInfo.view.scroll.left) ui.area.view.scrollTop(lastInfo.view.scroll.top) break } lastInfo.needRestore = false } } // view actions function refreshView () { ui.area.markdown.html('') isDirty = true updateViewInner() } var updateView = _.debounce(function () { editor.operation(updateViewInner) }, updateViewDebounce) var lastResult = null var postUpdateEvent = null function updateViewInner () { if (appState.currentMode === modeType.edit || !isDirty) return var value = editor.getValue() var lastMeta = md.meta md.meta = {} delete md.metaError var rendered = md.render(value) if (md.meta.type && md.meta.type === 'slide') { var slideOptions = { separator: '^(\r\n?|\n)---(\r\n?|\n)$', verticalSeparator: '^(\r\n?|\n)----(\r\n?|\n)$' } var slides = window.RevealMarkdown.slidify(editor.getValue(), slideOptions) ui.area.markdown.html(slides) window.RevealMarkdown.initialize() // prevent XSS ui.area.markdown.html(preventXSS(ui.area.markdown.html())) ui.area.markdown.addClass('slides') appState.syncscroll = false checkSyncToggle() } else { if (lastMeta.type && lastMeta.type === 'slide') { refreshView() ui.area.markdown.removeClass('slides') appState.syncscroll = true checkSyncToggle() } // only render again when meta changed if (JSON.stringify(md.meta) !== JSON.stringify(lastMeta)) { parseMeta(md, ui.area.codemirror, ui.area.markdown, $('#ui-toc'), $('#ui-toc-affix')) rendered = md.render(value) } // prevent XSS rendered = preventXSS(rendered) var result = postProcess(rendered).children().toArray() partialUpdate(result, lastResult, ui.area.markdown.children().toArray()) if (result && lastResult && result.length !== lastResult.length) { updateDataAttrs(result, ui.area.markdown.children().toArray()) } lastResult = $(result).clone() } removeDOMEvents(ui.area.markdown) finishView(ui.area.markdown) autoLinkify(ui.area.markdown) deduplicatedHeaderId(ui.area.markdown) renderTOC(ui.area.markdown) generateToc('ui-toc') generateToc('ui-toc-affix') generateScrollspy() updateScrollspy() smoothHashScroll() isDirty = false clearMap() // buildMap(); updateTitleReminder() if (postUpdateEvent && typeof postUpdateEvent === 'function') { postUpdateEvent() } } var updateHistoryDebounce = 600 var updateHistory = _.debounce(updateHistoryInner, updateHistoryDebounce) function updateHistoryInner () { writeHistory(renderFilename(ui.area.markdown), renderTags(ui.area.markdown)) } function updateDataAttrs (src, des) { // sync data attr startline and endline for (var i = 0; i < src.length; i++) { copyAttribute(src[i], des[i], 'data-startline') copyAttribute(src[i], des[i], 'data-endline') } } function partialUpdate (src, tar, des) { if (!src || src.length === 0 || !tar || tar.length === 0 || !des || des.length === 0) { ui.area.markdown.html(src) return } if (src.length === tar.length) { // same length for (let i = 0; i < src.length; i++) { copyAttribute(src[i], des[i], 'data-startline') copyAttribute(src[i], des[i], 'data-endline') var rawSrc = cloneAndRemoveDataAttr(src[i]) var rawTar = cloneAndRemoveDataAttr(tar[i]) if (rawSrc.outerHTML !== rawTar.outerHTML) { // console.log(rawSrc); // console.log(rawTar); $(des[i]).replaceWith(src[i]) } } } else { // diff length var start = 0 // find diff start position for (let i = 0; i < tar.length; i++) { // copyAttribute(src[i], des[i], 'data-startline'); // copyAttribute(src[i], des[i], 'data-endline'); let rawSrc = cloneAndRemoveDataAttr(src[i]) let rawTar = cloneAndRemoveDataAttr(tar[i]) if (!rawSrc || !rawTar || rawSrc.outerHTML !== rawTar.outerHTML) { start = i break } } // find diff end position var srcEnd = 0 var tarEnd = 0 for (let i = 0; i < src.length; i++) { // copyAttribute(src[i], des[i], 'data-startline'); // copyAttribute(src[i], des[i], 'data-endline'); let rawSrc = cloneAndRemoveDataAttr(src[i]) let rawTar = cloneAndRemoveDataAttr(tar[i]) if (!rawSrc || !rawTar || rawSrc.outerHTML !== rawTar.outerHTML) { start = i break } } // tar end for (let i = 1; i <= tar.length + 1; i++) { let srcLength = src.length let tarLength = tar.length // copyAttribute(src[srcLength - i], des[srcLength - i], 'data-startline'); // copyAttribute(src[srcLength - i], des[srcLength - i], 'data-endline'); let rawSrc = cloneAndRemoveDataAttr(src[srcLength - i]) let rawTar = cloneAndRemoveDataAttr(tar[tarLength - i]) if (!rawSrc || !rawTar || rawSrc.outerHTML !== rawTar.outerHTML) { tarEnd = tar.length - i break } } // src end for (let i = 1; i <= src.length + 1; i++) { let srcLength = src.length let tarLength = tar.length // copyAttribute(src[srcLength - i], des[srcLength - i], 'data-startline'); // copyAttribute(src[srcLength - i], des[srcLength - i], 'data-endline'); let rawSrc = cloneAndRemoveDataAttr(src[srcLength - i]) let rawTar = cloneAndRemoveDataAttr(tar[tarLength - i]) if (!rawSrc || !rawTar || rawSrc.outerHTML !== rawTar.outerHTML) { srcEnd = src.length - i break } } // check if tar end overlap tar start var overlap = 0 for (var i = start; i >= 0; i--) { var rawTarStart = cloneAndRemoveDataAttr(tar[i - 1]) var rawTarEnd = cloneAndRemoveDataAttr(tar[tarEnd + 1 + start - i]) if (rawTarStart && rawTarEnd && rawTarStart.outerHTML === rawTarEnd.outerHTML) { overlap++ } else { break } } if (debug) { console.log('overlap:' + overlap) } // show diff content if (debug) { console.log('start:' + start) console.log('tarEnd:' + tarEnd) console.log('srcEnd:' + srcEnd) } tarEnd += overlap srcEnd += overlap var repeatAdd = (start - srcEnd) < (start - tarEnd) var repeatDiff = Math.abs(srcEnd - tarEnd) - 1 // push new elements var newElements = [] if (srcEnd >= start) { for (let j = start; j <= srcEnd; j++) { if (!src[j]) continue newElements.push(src[j].outerHTML) } } else if (repeatAdd) { for (let j = srcEnd - repeatDiff; j <= srcEnd; j++) { if (!des[j]) continue newElements.push(des[j].outerHTML) } } // push remove elements var removeElements = [] if (tarEnd >= start) { for (let j = start; j <= tarEnd; j++) { if (!des[j]) continue removeElements.push(des[j]) } } else if (!repeatAdd) { for (let j = start; j <= start + repeatDiff; j++) { if (!des[j]) continue removeElements.push(des[j]) } } // add elements if (debug) { console.log('ADD ELEMENTS') console.log(newElements.join('\n')) } if (des[start]) { $(newElements.join('')).insertBefore(des[start]) } else { $(newElements.join('')).insertAfter(des[start - 1]) } // remove elements if (debug) { console.log('REMOVE ELEMENTS') } for (let j = 0; j < removeElements.length; j++) { if (debug) { console.log(removeElements[j].outerHTML) } if (removeElements[j]) { $(removeElements[j]).remove() } } } } function cloneAndRemoveDataAttr (el) { if (!el) return var rawEl = $(el).clone() rawEl.removeAttr('data-startline data-endline') rawEl.find('[data-startline]').removeAttr('data-startline data-endline') return rawEl[0] } function copyAttribute (src, des, attr) { if (src && src.getAttribute(attr) && des) { des.setAttribute(attr, src.getAttribute(attr)) } } if ($('.cursor-menu').length <= 0) { $("<div class='cursor-menu'>").insertAfter('.CodeMirror-cursors') } function reverseSortCursorMenu (dropdown) { var items = dropdown.find('.textcomplete-item') items.sort(function (a, b) { return $(b).attr('data-index') - $(a).attr('data-index') }) return items } var checkCursorMenu = _.throttle(checkCursorMenuInner, cursorMenuThrottle) function checkCursorMenuInner () { // get element var dropdown = $('.cursor-menu > .dropdown-menu') // return if not exists if (dropdown.length <= 0) return // set margin var menuRightMargin = 10 var menuBottomMargin = 4 // use sizer to get the real doc size (won't count status bar and gutters) var docWidth = ui.area.codemirrorSizer.width() // get editor size (status bar not count in) var editorHeight = ui.area.codemirror.height() // get element size var width = dropdown.outerWidth() var height = dropdown.outerHeight() // get cursor var cursor = editor.getCursor() // set element cursor data if (!dropdown.hasClass('CodeMirror-other-cursor')) { dropdown.addClass('CodeMirror-other-cursor') } dropdown.attr('data-line', cursor.line) dropdown.attr('data-ch', cursor.ch) // get coord position var coord = editor.charCoords({ line: cursor.line, ch: cursor.ch }, 'windows') var left = coord.left var top = coord.top // get doc top offset (to workaround with viewport) var docTopOffset = ui.area.codemirrorSizerInner.position().top // set offset var offsetLeft = 0 var offsetTop = defaultTextHeight // set up side down window.upSideDown = false var lastUpSideDown = window.upSideDown = false // only do when have width and height if (width > 0 && height > 0) { // make element right bound not larger than doc width if (left + width + offsetLeft + menuRightMargin > docWidth) { offsetLeft = -(left + width - docWidth + menuRightMargin) } // flip y when element bottom bound larger than doc height // and element top position is larger than element height if (top + docTopOffset + height + offsetTop + menuBottomMargin > Math.max(editor.doc.height, editorHeight) && top + docTopOffset > height + menuBottomMargin) { offsetTop = -(height + menuBottomMargin) // reverse sort menu because upSideDown dropdown.html(reverseSortCursorMenu(dropdown)) window.upSideDown = true } var textCompleteDropdown = $(editor.getInputField()).data('textComplete').dropdown lastUpSideDown = textCompleteDropdown.upSideDown textCompleteDropdown.upSideDown = window.upSideDown } // make menu scroll top only if upSideDown changed if (window.upSideDown !== lastUpSideDown) { dropdown.scrollTop(dropdown[0].scrollHeight) } // set element offset data dropdown.attr('data-offset-left', offsetLeft) dropdown.attr('data-offset-top', offsetTop) // set position dropdown[0].style.left = left + offsetLeft + 'px' dropdown[0].style.top = top + offsetTop + 'px' } function checkInIndentCode () { // if line starts with tab or four spaces is a code block var line = editor.getLine(editor.getCursor().line) var isIndentCode = ((line.substr(0, 4) === ' ') || (line.substr(0, 1) === '\t')) return isIndentCode } var isInCode = false function checkInCode () { isInCode = checkAbove(matchInCode) || checkInIndentCode() } function checkAbove (method) { var cursor = editor.getCursor() var text = [] for (var i = 0; i < cursor.line; i++) { // contain current line text.push(editor.getLine(i)) } text = text.join('\n') + '\n' + editor.getLine(cursor.line).slice(0, cursor.ch) // console.log(text); return method(text) } function checkBelow (method) { var cursor = editor.getCursor() var count = editor.lineCount() var text = [] for (var i = cursor.line + 1; i < count; i++) { // contain current line text.push(editor.getLine(i)) } text = editor.getLine(cursor.line).slice(cursor.ch) + '\n' + text.join('\n') // console.log(text); return method(text) } function matchInCode (text) { var match match = text.match(/`{3,}/g) if (match && match.length % 2) { return true } else { match = text.match(/`/g) if (match && match.length % 2) { return true } else { return false } } } var isInContainer = false var isInContainerSyntax = false function checkInContainer () { isInContainer = checkAbove(matchInContainer) && !checkInIndentCode() } function checkInContainerSyntax () { // if line starts with :::, it's in container syntax var line = editor.getLine(editor.getCursor().line) isInContainerSyntax = (line.substr(0, 3) === ':::') } function matchInContainer (text) { var match match = text.match(/:{3,}/g) if (match && match.length % 2) { return true } else { return false } } $(editor.getInputField()) .textcomplete([ { // emoji strategy match: /(^|\n|\s)\B:([-+\w]*)$/, search: function (term, callback) { var line = editor.getLine(editor.getCursor().line) term = line.match(this.match)[2] var list = [] $.map(window.emojify.emojiNames, function (emoji) { if (emoji.indexOf(term) === 0) { // match at first character list.push(emoji) } }) $.map(window.emojify.emojiNames, function (emoji) { if (emoji.indexOf(term) !== -1) { // match inside the word list.push(emoji) } }) callback(list) }, template: function (value) { return '<img class="emoji" src="' + serverurl + '/build/emojify.js/dist/images/basic/' + value + '.png"></img> ' + value }, replace: function (value) { return '$1:' + value + ': ' }, index: 1, context: function (text) { checkInCode() checkInContainer() checkInContainerSyntax() return !isInCode && !isInContainerSyntax } }, { // Code block language strategy langs: supportCodeModes, charts: supportCharts, match: /(^|\n)```(\w+)$/, search: function (term, callback) { var line = editor.getLine(editor.getCursor().line) term = line.match(this.match)[2] var list = [] $.map(this.langs, function (lang) { if (lang.indexOf(term) === 0 && lang !== term) { list.push(lang) } }) $.map(this.charts, function (chart) { if (chart.indexOf(term) === 0 && chart !== term) { list.push(chart) } }) callback(list) }, replace: function (lang) { var ending = '' if (!checkBelow(matchInCode)) { ending = '\n\n```' } if (this.langs.indexOf(lang) !== -1) { return '$1```' + lang + '=' + ending } else if (this.charts.indexOf(lang) !== -1) { return '$1```' + lang + ending } }, done: function () { var cursor = editor.getCursor() var text = [] text.push(editor.getLine(cursor.line - 1)) text.push(editor.getLine(cursor.line)) text = text.join('\n') // console.log(text); if (text === '\n```') { editor.doc.cm.execCommand('goLineUp') } }, context: function (text) { return isInCode } }, { // Container strategy containers: supportContainers, match: /(^|\n):::(\s*)(\w*)$/, search: function (term, callback) { var line = editor.getLine(editor.getCursor().line) term = line.match(this.match)[3].trim() var list = [] $.map(this.containers, function (container) { if (container.indexOf(term) === 0 && container !== term) { list.push(container) } }) callback(list) }, replace: function (lang) { var ending = '' if (!checkBelow(matchInContainer)) { ending = '\n\n:::' } if (this.containers.indexOf(lang) !== -1) { return '$1:::$2' + lang + ending } }, done: function () { var cursor = editor.getCursor() var text = [] text.push(editor.getLine(cursor.line - 1)) text.push(editor.getLine(cursor.line)) text = text.join('\n') // console.log(text); if (text === '\n:::') { editor.doc.cm.execCommand('goLineUp') } }, context: function (text) { return !isInCode && isInContainer } }, { // header match: /(?:^|\n)(\s{0,3})(#{1,6}\w*)$/, search: function (term, callback) { callback($.map(supportHeaders, function (header) { return header.search.indexOf(term) === 0 ? header.text : null })) }, replace: function (value) { return '$1' + value }, context: function (text) { return !isInCode } }, { // extra tags for list match: /(^[>\s]*[-+*]\s(?:\[[x ]\]|.*))(\[\])(\w*)$/, search: function (term, callback) { var list = [] $.map(supportExtraTags, function (extratag) { if (extratag.search.indexOf(term) === 0) { list.push(extratag.command()) } }) $.map(supportReferrals, function (referral) { if (referral.search.indexOf(term) === 0) { list.push(referral.text) } }) callback(list) }, replace: function (value) { return '$1' + value }, context: function (text) { return !isInCode } }, { // extra tags for blockquote match: /(?:^|\n|\s)(>.*|\s|)((\^|)\[(\^|)\](\[\]|\(\)|:|)\s*\w*)$/, search: function (term, callback) { var line = editor.getLine(editor.getCursor().line) var quote = line.match(this.match)[1].trim() var list = [] if (quote.indexOf('>') === 0) { $.map(supportExtraTags, function (extratag) { if (extratag.search.indexOf(term) === 0) { list.push(extratag.command()) } }) } $.map(supportReferrals, function (referral) { if (referral.search.indexOf(term) === 0) { list.push(referral.text) } }) callback(list) }, replace: function (value) { return '$1' + value }, context: function (text) { return !isInCode } }, { // referral match: /(^\s*|\n|\s{2})((\[\]|\[\]\[\]|\[\]\(\)|!|!\[\]|!\[\]\[\]|!\[\]\(\))\s*\w*)$/, search: function (term, callback) { callback($.map(supportReferrals, function (referral) { return referral.search.indexOf(term) === 0 ? referral.text : null })) }, replace: function (value) { return '$1' + value }, context: function (text) { return !isInCode } }, { // externals match: /(^|\n|\s)\{\}(\w*)$/, search: function (term, callback) { callback($.map(supportExternals, function (external) { return external.search.indexOf(term) === 0 ? external.text : null })) }, replace: function (value) { return '$1' + value }, context: function (text) { return !isInCode } } ], { appendTo: $('.cursor-menu') }) .on({ 'textComplete:beforeSearch': function (e) { // NA }, 'textComplete:afterSearch': function (e) { checkCursorMenu() }, 'textComplete:select': function (e, value, strategy) { // NA }, 'textComplete:show': function (e) { $(this).data('autocompleting', true) editor.setOption('extraKeys', { 'Up': function () { return false }, 'Right': function () { editor.doc.cm.execCommand('goCharRight') }, 'Down': function () { return false }, 'Left': function () { editor.doc.cm.execCommand('goCharLeft') }, 'Enter': function () { return false }, 'Backspace': function () { editor.doc.cm.execCommand('delCharBefore') } }) }, 'textComplete:hide': function (e) { $(this).data('autocompleting', false) editor.setOption('extraKeys', editorInstance.defaultExtraKeys) } })