/* eslint-env browser, jquery */ /* eslint no-console: ["error", { allow: ["warn", "error", "debug"] }] */ /* global Cookies, moment, serverurl, key, Dropbox, Visibility */ import TurndownService from 'turndown' import CodeMirror from '@hedgedoc/codemirror-5/lib/codemirror.js' import 'jquery-ui/ui/widgets/resizable' import 'jquery-ui/themes/base/resizable.css' import Idle from 'Idle.Js' import '../vendor/jquery-textcomplete/jquery.textcomplete' import { ot } from '../vendor/ot/ot.min.js' import hex2rgb from '../vendor/ot/hex2rgb' import { saveAs } from 'file-saver' import randomColor from 'randomcolor' import store from 'store' import url from 'wurl' import { Spinner } from 'spin.js' 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' 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') require('./fix-aria-hidden-for-modals') let defaultTextHeight = 20 let viewportMargin = 20 const defaultEditorMode = 'gfm' const idleTime = 300000 // 5 mins const updateViewDebounce = 100 const cursorMenuThrottle = 50 const cursorActivityDebounce = 50 const cursorAnimatePeriod = 100 const supportContainers = ['success', 'info', 'warning', 'danger'] const supportCodeModes = [ '1c', 'abnf', 'accesslog', 'actionscript', 'ada', 'angelscript', 'apache', 'applescript', 'arcade', 'arduino', 'armasm', 'asciidoc', 'aspectj', 'autohotkey', 'autoit', 'avrasm', 'awk', 'axapta', 'bash', 'basic', 'bnf', 'brainfuck', 'cal', 'capnproto', 'ceylon', 'c', 'clean', 'c-like', 'clojure', 'clojure-repl', 'cmake', 'coffeescript', 'coq', 'cos', 'cpp', 'crmsh', 'crystal', 'csharp', 'csp', 'css', 'dart', 'delphi', 'diff', 'django', 'd', 'dns', 'dockerfile', 'dos', 'dsconfig', 'dts', 'dust', 'ebnf', 'elixir', 'elm', 'erb', 'erlang', 'erlang-repl', 'excel', 'fix', 'flix', 'fortran', 'fsharp', 'gams', 'gauss', 'gcode', 'gherkin', 'glsl', 'gml', 'go', 'golo', 'gradle', 'groovy', 'haml', 'handlebars', 'haskell', 'haxe', 'hsp', 'htmlbars', 'http', 'hy', 'inform7', 'ini', 'irpf90', 'isbl', 'java', 'javascript', 'jboss-cli', 'json', 'julia', 'julia-repl', 'kotlin', 'lasso', 'latex', 'ldif', 'leaf', 'less', 'lisp', 'livecodeserver', 'livescript', 'llvm', 'lsl', 'lua', 'makefile', 'markdown', 'mathematica', 'matlab', 'maxima', 'mel', 'mercury', 'mipsasm', 'mizar', 'mojolicious', 'monkey', 'moonscript', 'n1ql', 'nginx', 'nim', 'nix', 'node-repl', 'nsis', 'objectivec', 'ocaml', 'openscad', 'oxygene', 'parser3', 'perl', 'pf', 'pgsql', 'php', 'php-template', 'plaintext', 'pony', 'powershell', 'processing', 'profile', 'prolog', 'properties', 'protobuf', 'puppet', 'purebasic', 'python', 'python-repl', 'q', 'qml', 'reasonml', 'rib', 'r', 'roboconf', 'routeros', 'rsl', 'ruby', 'ruleslanguage', 'rust', 'sas', 'scala', 'scheme', 'scilab', 'scss', 'shell', 'smali', 'smalltalk', 'sml', 'sqf', 'sql', 'sql_more', 'stan', 'stata', 'step21', 'stylus', 'subunit', 'swift', 'taggerscript', 'tap', 'tcl', 'thrift', 'tp', 'twig', 'typescript', 'vala', 'vbnet', 'vbscript-html', 'vbscript', 'verilog', 'vhdl', 'vim', 'x86asm', 'xl', 'xml', 'xquery', 'yaml', 'zephir' ] const supportCharts = ['sequence', 'flow', 'graphviz', 'mermaid', 'abc'] const 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: '', search: '![]()' }, { text: '', 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 () { const 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 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() const editor = editorInstance.init(textit) // FIXME: global referncing in jquery-textcomplete patch window.editor = editor defaultTextHeight = parseInt($('.CodeMirror').css('line-height')) // initalize ui reference const ui = getUIElements() // page actions const 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 */ const spinner = new Spinner(opts).spin(ui.spinner[0]) /* eslint-enable no-unused-vars */ // idle const 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() }) let 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 let wasFocus = false Visibility.change(function (e, state) { const 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 () { // set global ajax timeout $.ajaxSetup({ timeout: 10000 }) idle.checkAway() checkResponsive() // if in smaller screen, we don't need advanced scrollbar let scrollbarStyle if (visibleXS) { scrollbarStyle = 'native' } else { scrollbarStyle = 'overlay' } if (scrollbarStyle !== editor.getOption('scrollbarStyle')) { editor.setOption('scrollbarStyle', scrollbarStyle) clearMap() } checkEditorStyle() /* cache dom references */ const $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') }) } if (Cookies.get('nightMode') !== undefined) { store.set('nightMode', Cookies.get('nightMode') === 'true') Cookies.remove('nightMode') } // Re-enable nightmode if (store.get('nightMode') === true) { $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() } } const windowResizeDebounce = 200 const 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 (let 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 () { const 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() } let lastEditorWidth = 0 let previousFocusOnEditor = null function checkEditorStyle () { let 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 const 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') } } const checkEditorScrollbar = _.debounce(function () { editor.operation(checkEditorScrollbarInner) }, 50) function checkEditorScrollbarInner () { // workaround simple scroll bar knob // will get wrong position when editor height changed const scrollInfo = editor.getScrollInfo() editor.scrollTo(null, scrollInfo.top - 1) editor.scrollTo(null, scrollInfo.top) } function checkTocStyle () { // toc right const paddingRight = parseFloat(ui.area.markdown.css('padding-right')) const right = $(window).width() - (ui.area.markdown.offset().left + ui.area.markdown.outerWidth() - paddingRight) ui.toc.toc.css('right', right + 'px') // affix toc left let newbool const rightMargin = (ui.area.markdown.parent().outerWidth() - ui.area.markdown.outerWidth()) / 2 // for ipad or wider device if (rightMargin >= 133) { newbool = true const affixLeftMargin = (ui.toc.affix.outerWidth() - ui.toc.affix.width()) / 2 const 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 const shortStatus = ui.toolbar.shortStatus const status = ui.toolbar.status const label = $('<span class="label"></span>') const fa = $('<i class="fa"></i>') let msg = '' let 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) const 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 } } let lastMode = null function changeMode (type) { // lock navbar to prevent it hide after changeMode lockNavbar() saveInfo() if (type) { lastMode = appState.currentMode appState.currentMode = type } const responsiveClass = 'col-lg-6 col-md-6 col-sm-6' const 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 ) { // 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) } 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') const 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') } const unlockNavbar = _.debounce(function () { $('.navbar').removeClass('locked') }, 200) function showMessageModal (title, header, href, text, success) { const 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() const filename = renderFilename(ui.area.markdown) + '.md' const markdown = editor.getValue() const 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) }) // export to dropbox ui.toolbar.export.dropbox.click(function (event) { event.preventDefault() const filename = renderFilename(ui.area.markdown) + '.md' const options = { files: [ { url: noteurl + '/download', 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) $('#snippetExportModalVersion').val(data.version) $('#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 (event) { event.preventDefault() const options = { success: function (files) { ui.spinner.show() const 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) $('#snippetImportModalVersion').val(data.version) $('#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() }) }) // toc ui.toc.dropdown.click(function (e) { e.stopPropagation() }) // prevent empty link change hash $('a[href="#"]').click(function (e) { e.preventDefault() }) // modal actions let revisions = [] let revisionViewer = null let revisionInsert = [] let revisionDelete = [] let revisionInsertAnnotation = null let revisionDeleteAnnotation = null const revisionList = ui.modal.revision.find('.ui-revision-list') let revision = null let 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) { // eslint-disable-next-line no-console console.debug(err) } }) .always(function () { // na }) }) function checkRevisionViewer () { if (revisionViewer) { const 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 let lastRevision = null if (revisionList.children().length > 0) { lastRevision = revisionList.find('.active').attr('data-revision-time') } revisionList.html('') for (let i = 0; i < revisions.length; i++) { const revision = revisions[i] const item = $('<a class="list-group-item"></a>') item.attr('data-revision-time', revision.time) if (lastRevision === revision.time) item.addClass('active') const itemHeading = $('<h5 class="list-group-item-heading"></h5>') itemHeading.html( '<i class="fa fa-clock-o"></i> ' + moment(revision.time).format('llll') ) const 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) { const 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 const lastScrollInfo = revisionViewer.getScrollInfo() revisionList.children().removeClass('active') revisionList .find('[data-revision-time="' + time + '"]') .addClass('active') const 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) { let bias = 0 for (let j = 0; j < revision.patch.length; j++) { const patch = revision.patch[j] let currIndex = patch.start1 + bias for (let i = 0; i < patch.diffs.length; i++) { const diff = patch.diffs[i] // ignore if diff only contains line breaks if ((diff[1].match(/\n/g) || []).length === diff[1].length) { continue } let prePos, 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) { // eslint-disable-next-line no-console console.debug(err) } }) .always(function () { // na }) } function initRevisionViewer () { if (revisionViewer) return const revisionViewerTextArea = document.getElementById('revisionViewer') revisionViewer = CodeMirror.fromTextArea(revisionViewerTextArea, { mode: defaultEditorMode, 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 const filename = renderFilename(ui.area.markdown) + '_' + revisionTime + '.md' const 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 () { const accesstoken = $('#snippetImportModalAccessToken').val() const baseURL = $('#snippetImportModalBaseURL').val() const project = $('#snippetImportModalProjects').val() const version = $('#snippetImportModalVersion').val() $('#snippetImportModalLoading').show() $('#snippetImportModalContent').val('/projects/' + project) $.get( baseURL + '/api/' + version + '/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) { // eslint-disable-next-line no-console console.debug(err) } }) .always(function () { // na }) }) // snippet snippets ui.modal.snippetImportSnippets.change(function () { const 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) { const scrollInfo = editor.getScrollInfo() const 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 let 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 () { const headers = ui.area.markdown.find('h1, h2, h3').toArray() const headerMap = [] for (let 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 ) const 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) { let index = 0 for (let i = headerMap.length - 1; i >= 0; i--) { if ( top >= headerMap[i] + offset && headerMap[i + 1] && top < headerMap[i + 1] + offset ) { index = i break } } const header = $(headers[index]) const 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 () { const data = $('#clipboardModalContent').text() 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 () { const 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 { const hostname = 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/' + url('-1', gisturl)) .done(function (data) { if (data.files) { let 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 () { const 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() const accessToken = '?access_token=' + $('#snippetImportModalAccessToken').val() const fullURL = $('#snippetImportModalBaseURL').val() + '/api/' + $('#snippetImportModalVersion').val() + snippeturl $.get(fullURL + accessToken) .done(function (data) { let content = '# ' + (data.title || 'Snippet Import') const 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 () { const accesstoken = $('#snippetExportModalAccessToken').val() const baseURL = $('#snippetExportModalBaseURL').val() const version = $('#snippetExportModalVersion').val() const projectId = $('#snippetExportModalProjects').val() const visibilityValue = $('#snippetExportModalVisibility').val() const data = { title: $('#snippetExportModalTitle').val(), files: [ { file_path: $('#snippetExportModalFileName').val(), content: editor.getValue() } ], visibility: visibilityValue === '0' ? 'private' : visibilityValue === '10' ? 'internal' : 'private' } if ( !data.title || !data.files[0].file_path || !data.files[0].content || !projectId ) { return } $('#snippetExportModalLoading').show() const fullURL = `${baseURL}/api/${version}/projects/${projectId}/snippets?access_token=${accesstoken}` $.ajax(fullURL, { data: JSON.stringify(data), contentType: 'application/json', type: 'POST', success: function (ret) { $('#snippetExportModalLoading').hide() $('#snippetExportModal').modal('hide') showMessageModal( '<i class="fa fa-gitlab"></i> Export to Snippet', 'Export Successful!', ret.web_url, 'View Snippet Here', true ) } }) }) function parseToEditor (data) { const turndownService = new TurndownService({ defaultReplacement: function (innerHTML, node) { return node.isBlock ? '\n\n' + node.outerHTML + '\n\n' : node.outerHTML } }) const parsed = turndownService.turndown(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.debug(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, success: function (data) { const 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 () { const $body = $('body') const isActive = store.get('nightMode') === true $body.toggleClass('night', !isActive) ui.toolbar.night.toggleClass('active', !isActive) store.set('nightMode', !isActive) } function emitPermission (_permission) { if (_permission !== permission) { socket.emit('permission', _permission) } } function updatePermission (newPermission) { if (permission !== newPermission) { permission = newPermission if (window.loaded) refreshView() } let label = null let 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 () { let 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 const io = require('socket.io-client') const 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 const on = socket.on socket.on = function () { if (!checkLoginStateChanged() && !needRefresh) { return on.apply(socket, arguments) } } const 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) { const newnotehistory = removeHistory(noteid, notehistory) saveHistory(newnotehistory) location.href = serverurl }) } }) let 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') } } }) let authors = [] let authorship = [] let authorMarks = {} // temp variable let addTextMarkers = [] // temp variable function updateInfo (data) { // console.debug(data); if ( Object.prototype.hasOwnProperty.call(data, 'createtime') && window.createtime !== data.createtime ) { window.createtime = data.createtime updateLastChange() } if ( Object.prototype.hasOwnProperty.call(data, 'updatetime') && window.lastchangetime !== data.updatetime ) { window.lastchangetime = data.updatetime updateLastChange() } if ( Object.prototype.hasOwnProperty.call(data, 'owner') && window.owner !== data.owner ) { window.owner = data.owner window.ownerprofile = data.ownerprofile updateOwner() } if ( Object.prototype.hasOwnProperty.call(data, 'lastchangeuser') && window.lastchangeuser !== data.lastchangeuser ) { window.lastchangeuser = data.lastchangeuser window.lastchangeuserprofile = data.lastchangeuserprofile updateLastChangeUser() updateOwner() } if ( Object.prototype.hasOwnProperty.call(data, 'authors') && authors !== data.authors ) { authors = data.authors } if ( Object.prototype.hasOwnProperty.call(data, 'authorship') && authorship !== data.authorship ) { authorship = data.authorship updateAuthorship() } } const 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 } const addStyleRule = (function () { const added = {} const styleElement = document.createElement('style') document.documentElement .getElementsByTagName('head')[0] .appendChild(styleElement) const 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++) { const atom = authorship[i] const author = authors[atom[0]] if (author) { const prePos = editor.posFromIndex(atom[1]) const preLine = editor.getLine(prePos.line) const postPos = editor.posFromIndex(atom[2]) const 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) { let startLine = prePos.line let endLine = postPos.line if (prePos.ch === preLine.length) { startLine++ } else if (prePos.ch !== 0) { const mark = initMarkAndCheckGutter( authorMarks[prePos.line], author, atom[3] ) const _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) { const mark = initMarkAndCheckGutter( authorMarks[postPos.line], author, atom[3] ) const _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 { const 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) const allTextMarks = editor.getAllMarks() for (let i = 0; i < allTextMarks.length; i++) { const _textMarker = allTextMarks[i] const pos = _textMarker.find() let found = false for (let j = 0; j < addTextMarkers.length; j++) { const textMarker = addTextMarkers[j] const author = authors[textMarker.userid] const className = 'authorship-inline-' + author.color.substr(1) const 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++) { const textMarker = addTextMarkers[i] const 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);` const 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) { const lineNumber = line.lineNo() const currMark = authorMarks[lineNumber] const author = currMark ? authors[currMark.gutter.userid] : null if (currMark && author) { const 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) const 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 (let i = 0; i < currMark.textmarkers.length; i++) { const 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) { const $ele = $(element) if ($('[aria-describedby="' + $ele.attr('id') + '"]').length <= 0) { $ele.remove() } }) }) socket.on('check', function (data) { // console.debug(data); updateInfo(data) }) socket.on('permission', function (data) { updatePermission(data.permission) }) let permission = null socket.on('refresh', function (data) { // console.debug(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 const 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) { const 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) } }) const EditorClient = ot.EditorClient const SocketIOAdapter = ot.SocketIOAdapter const CodeMirrorAdapter = ot.CodeMirrorAdapter let cmClient = null let synchronized_ = null function havePendingOperation () { return !!( cmClient && cmClient.state && Object.prototype.hasOwnProperty.call(cmClient, 'outstanding') ) } socket.on('doc', function (obj) { const body = obj.str const bodyMismatch = editor.getValue() !== body const 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) { let found = false for (let i = 0; i < data.users.length; i++) { const user = data.users[i] if ($(this).attr('id') === user.id) { found = true } } if (!found) { $(this) .stop(true) .fadeOut('normal', function () { $(this).remove() }) } }) for (let i = 0; i < data.users.length; i++) { const 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 (let 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 (let 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 const 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 (let 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 (let 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 const cursor = $('div[data-clientid="' + data.id + '"]') if (cursor.length > 0) { cursor.stop(true).fadeOut() } }) const 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>' } const onlineUserList = new List('online-user-list', options) const shortOnlineUserList = new List('short-online-user-list', options) function updateOnlineStatus () { if (!window.loaded || !socket.connected) return const _onlineUsers = deduplicateOnlineUsers(onlineUsers) showStatus(statusType.online, _onlineUsers.length) const 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 } } const 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) { const usera = a.values() const userb = b.values() const useraIsSelf = usera.id === personalInfo.id || (usera.login && usera.userid === personalInfo.userid) const 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) { const items = list.items for (let j = 0; j < items.length; j++) { const item = items[j] const userstatus = $(item.elm).find('.ui-user-status') const 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) { const _onlineUsers = [] for (let i = 0; i < list.length; i++) { const user = $.extend({}, list[i]) if (!user.userid) { _onlineUsers.push(user) } else { let found = false for (let 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 } let userStatusCache = null function emitUserStatus (force) { if (!window.loaded) return let 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 (let i = 0; i < onlineUsers.length; i++) { if (onlineUsers[i].id === personalInfo.id) { onlineUsers[i] = personalInfo } } const userStatus = { idle: idle.isAway, 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 const tagRightMargin = 0 const tagBottomMargin = 2 // use sizer to get the real doc size (won't count status bar and gutters) const docWidth = ui.area.codemirrorSizer.width() // get editor size (status bar not count in) const editorHeight = ui.area.codemirror.height() // get element size const width = ele.outerWidth() const height = ele.outerHeight() const padding = (ele.outerWidth() - ele.width()) / 2 // get coord position const left = coord.left const top = coord.top // get doc top offset (to workaround with viewport) const docTopOffset = ui.area.codemirrorSizerInner.position().top // set offset let offsetLeft = -3 let 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 const coord = editor.charCoords(user.cursor, 'windows') coord.left = coord.left < 4 ? 4 : coord.left coord.top = coord.top < 0 ? 0 : coord.top let 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) { const 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) const cursorbar = $('<div class="cursorbar"> </div>') cursorbar[0].style.height = defaultTextHeight + 'px' cursorbar[0].style.borderLeft = '2px solid ' + user.color const icon = '<i class="fa ' + iconClass + '"></i>' const 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') } } ) const hideCursorTagDelay = 2000 let hideCursorTagTimer = null const 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') } } const switchTag = function (ele) { if (ele.css('display') === 'none') { ele.stop(true).fadeIn('fast') } else { ele.stop(true).fadeOut('fast') } } const hideCursorTag = function () { if (cursor.attr('data-mode') === 'hover') { cursortag.fadeOut('fast') } } cursor.on('touchstart', function (e) { const 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 { const cursor = $('div[data-clientid="' + user.id + '"]') cursor.attr('data-line', user.cursor.line) cursor.attr('data-ch', user.cursor.ch) const cursorbar = cursor.find('.cursorbar') cursorbar[0].style.height = defaultTextHeight + 'px' cursorbar[0].style.borderLeft = '2px solid ' + user.color const 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) { const str = change.text.join('\n') // eslint-disable-next-line no-control-regex if (/\u0000/g.test(str) && change.update) { change.update( change.from, change.to, // eslint-disable-next-line no-control-regex str.replace(/\u0000/g, '').split('\n') ) } } function enforceMaxLength (cm, change) { const maxLength = cm.getOption('maxLength') if (maxLength && change.update) { let str = change.text.join('\n') let 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 } const 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') } const 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() const docLength = editor.getValue().length // workaround for big documents let 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 (let 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 (let 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) { let 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 (let 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 () { const scrollbarStyle = editor.getOption('scrollbarStyle') const left = $(window).scrollLeft() const 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 () { const scrollbarStyle = editor.getOption('scrollbarStyle') if (lastInfo.needRestore) { const line = lastInfo.edit.cursor.line const 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 { const left = lastInfo.edit.scroll.left const 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: editor.scrollIntoView() editor.scrollTo(lastInfo.edit.scroll.left, lastInfo.edit.scroll.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() } const updateView = _.debounce(function () { editor.operation(updateViewInner) }, updateViewDebounce) let lastResult = null let postUpdateEvent = null function updateViewInner () { if (appState.currentMode === modeType.edit || !isDirty) return const value = editor.getValue() const lastMeta = md.meta md.meta = {} delete md.metaError let rendered = md.render(value) if (md.meta.type && md.meta.type === 'slide') { ui.area.view.addClass('black') const slideOptions = { separator: '^(\r\n?|\n)---(\r\n?|\n)$', verticalSeparator: '^(\r\n?|\n)----(\r\n?|\n)$' } const 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') ui.area.view.removeClass('black') 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) const 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') autoLinkify(ui.area.markdown) generateScrollspy() updateScrollspy() smoothHashScroll() isDirty = false clearMap() // buildMap(); updateTitleReminder() if (postUpdateEvent && typeof postUpdateEvent === 'function') { postUpdateEvent() } } const updateHistoryDebounce = 600 const 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 (let 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') const rawSrc = cloneAndRemoveDataAttr(src[i]) const rawTar = cloneAndRemoveDataAttr(tar[i]) if (rawSrc.outerHTML !== rawTar.outerHTML) { // console.debug(rawSrc); // console.debug(rawTar); $(des[i]).replaceWith(src[i]) } } } else { // diff length let 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'); const rawSrc = cloneAndRemoveDataAttr(src[i]) const rawTar = cloneAndRemoveDataAttr(tar[i]) if (!rawSrc || !rawTar || rawSrc.outerHTML !== rawTar.outerHTML) { start = i break } } // find diff end position let srcEnd = 0 let tarEnd = 0 for (let i = 0; i < src.length; i++) { // copyAttribute(src[i], des[i], 'data-startline'); // copyAttribute(src[i], des[i], 'data-endline'); const rawSrc = cloneAndRemoveDataAttr(src[i]) const 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++) { const srcLength = src.length const tarLength = tar.length // copyAttribute(src[srcLength - i], des[srcLength - i], 'data-startline'); // copyAttribute(src[srcLength - i], des[srcLength - i], 'data-endline'); const rawSrc = cloneAndRemoveDataAttr(src[srcLength - i]) const 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++) { const srcLength = src.length const tarLength = tar.length // copyAttribute(src[srcLength - i], des[srcLength - i], 'data-startline'); // copyAttribute(src[srcLength - i], des[srcLength - i], 'data-endline'); const rawSrc = cloneAndRemoveDataAttr(src[srcLength - i]) const rawTar = cloneAndRemoveDataAttr(tar[tarLength - i]) if (!rawSrc || !rawTar || rawSrc.outerHTML !== rawTar.outerHTML) { srcEnd = src.length - i break } } // check if tar end overlap tar start let overlap = 0 for (let i = start; i >= 0; i--) { const rawTarStart = cloneAndRemoveDataAttr(tar[i - 1]) const rawTarEnd = cloneAndRemoveDataAttr(tar[tarEnd + 1 + start - i]) if ( rawTarStart && rawTarEnd && rawTarStart.outerHTML === rawTarEnd.outerHTML ) { overlap++ } else { break } } if (debug) { console.debug('overlap:' + overlap) } // show diff content if (debug) { console.debug('start:' + start) console.debug('tarEnd:' + tarEnd) console.debug('srcEnd:' + srcEnd) } tarEnd += overlap srcEnd += overlap const repeatAdd = start - srcEnd < start - tarEnd const repeatDiff = Math.abs(srcEnd - tarEnd) - 1 // push new elements const 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 const 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.debug('ADD ELEMENTS') console.debug(newElements.join('\n')) } if (des[start]) { $(newElements.join('')).insertBefore(des[start]) } else { $(newElements.join('')).insertAfter(des[start - 1]) } // remove elements if (debug) { console.debug('REMOVE ELEMENTS') } for (let j = 0; j < removeElements.length; j++) { if (debug) { console.debug(removeElements[j].outerHTML) } if (removeElements[j]) { $(removeElements[j]).remove() } } } } function cloneAndRemoveDataAttr (el) { if (!el) return const 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) { const items = dropdown.find('.textcomplete-item') items.sort(function (a, b) { return $(b).attr('data-index') - $(a).attr('data-index') }) return items } const checkCursorMenu = _.throttle(checkCursorMenuInner, cursorMenuThrottle) function checkCursorMenuInner () { // get element const dropdown = $('.cursor-menu > .dropdown-menu') // return if not exists if (dropdown.length <= 0) return // set margin const menuRightMargin = 10 const menuBottomMargin = 4 // use sizer to get the real doc size (won't count status bar and gutters) const docWidth = ui.area.codemirrorSizer.width() // get editor size (status bar not count in) const editorHeight = ui.area.codemirror.height() // get element size const width = dropdown.outerWidth() const height = dropdown.outerHeight() // get cursor const 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 const coord = editor.charCoords( { line: cursor.line, ch: cursor.ch }, 'windows' ) const left = coord.left const top = coord.top // get doc top offset (to workaround with viewport) const docTopOffset = ui.area.codemirrorSizerInner.position().top // set offset let offsetLeft = 0 let offsetTop = defaultTextHeight // set up side down window.upSideDown = false let 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 } const 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 const line = editor.getLine(editor.getCursor().line) const isIndentCode = line.substr(0, 4) === ' ' || line.substr(0, 1) === '\t' return isIndentCode } let isInCode = false function checkInCode () { isInCode = checkAbove(matchInCode) || checkInIndentCode() } function checkAbove (method) { const cursor = editor.getCursor() let text = [] for (let 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.debug(text); return method(text) } function checkBelow (method) { const cursor = editor.getCursor() const count = editor.lineCount() let text = [] for (let 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.debug(text); return method(text) } function matchInCode (text) { let 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 } } } let isInContainer = false let isInContainerSyntax = false function checkInContainer () { isInContainer = checkAbove(matchInContainer) && !checkInIndentCode() } function checkInContainerSyntax () { // if line starts with :::, it's in container syntax const line = editor.getLine(editor.getCursor().line) isInContainerSyntax = line.substr(0, 3) === ':::' } function matchInContainer (text) { const 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) { const line = editor.getLine(editor.getCursor().line) term = line.match(this.match)[2] const 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) { const line = editor.getLine(editor.getCursor().line) term = line.match(this.match)[2] const 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) { let 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 () { const cursor = editor.getCursor() let text = [] text.push(editor.getLine(cursor.line - 1)) text.push(editor.getLine(cursor.line)) text = text.join('\n') // console.debug(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) { const line = editor.getLine(editor.getCursor().line) term = line.match(this.match)[3].trim() const list = [] $.map(this.containers, function (container) { if (container.indexOf(term) === 0 && container !== term) { list.push(container) } }) callback(list) }, replace: function (lang) { let ending = '' if (!checkBelow(matchInContainer)) { ending = '\n\n:::' } if (this.containers.indexOf(lang) !== -1) { return '$1:::$2' + lang + ending } }, done: function () { const cursor = editor.getCursor() let text = [] text.push(editor.getLine(cursor.line - 1)) text.push(editor.getLine(cursor.line)) text = text.join('\n') // console.debug(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) { const 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) { const line = editor.getLine(editor.getCursor().line) const quote = line.match(this.match)[1].trim() const 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) } })