mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-25 11:16:31 -05:00
0f7f11e4f3
jQuery's .html() method escapes contained text (e.g. '<' becomes '<'). This confuses the turndown parser, which then only performs unescaping, but does not convert to markdown. By using .text() instead, the unescaped content is returned and turndown can correctly generate markdown. Signed-off-by: David Mehren <git@herrmehren.de>
3985 lines
106 KiB
JavaScript
3985 lines
106 KiB
JavaScript
/* eslint-env browser, jquery */
|
|
/* eslint no-console: ["error", { allow: ["warn", "error", "debug"] }] */
|
|
/* global CodeMirror, Cookies, moment, Idle, serverurl,
|
|
key, Dropbox, ot, hex2rgb, Visibility */
|
|
|
|
import TurndownService from 'turndown'
|
|
|
|
import { saveAs } from 'file-saver'
|
|
import randomColor from 'randomcolor'
|
|
import store from 'store'
|
|
import hljs from 'highlight.js'
|
|
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')
|
|
|
|
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 = [
|
|
'javascript',
|
|
'typescript',
|
|
'jsx',
|
|
'htmlmixed',
|
|
'htmlembedded',
|
|
'css',
|
|
'xml',
|
|
'clike',
|
|
'clojure',
|
|
'ruby',
|
|
'python',
|
|
'shell',
|
|
'php',
|
|
'sql',
|
|
'haskell',
|
|
'coffeescript',
|
|
'yaml',
|
|
'pug',
|
|
'lua',
|
|
'cmake',
|
|
'nginx',
|
|
'perl',
|
|
'sass',
|
|
'r',
|
|
'dockerfile',
|
|
'tiddlywiki',
|
|
'mediawiki',
|
|
'go',
|
|
'gherkin'
|
|
].concat(hljs.listLanguages())
|
|
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: '![image alt](https:// "title")',
|
|
search: '![]()'
|
|
},
|
|
{
|
|
text: '![image alt](https:// "title" =WidthxHeight)',
|
|
search: '![]()'
|
|
},
|
|
{
|
|
text: '[TOC]',
|
|
search: '[]'
|
|
}
|
|
]
|
|
const supportExternals = [
|
|
{
|
|
text: '{%youtube youtubeid %}',
|
|
search: 'youtube'
|
|
},
|
|
{
|
|
text: '{%vimeo vimeoid %}',
|
|
search: 'vimeo'
|
|
},
|
|
{
|
|
text: '{%gist gistid %}',
|
|
search: 'gist'
|
|
},
|
|
{
|
|
text: '{%slideshare slideshareid %}',
|
|
search: 'slideshare'
|
|
},
|
|
{
|
|
text: '{%speakerdeck speakerdeckid %}',
|
|
search: 'speakerdeck'
|
|
},
|
|
{
|
|
text: '{%pdf pdfurl %}',
|
|
search: 'pdf'
|
|
}
|
|
]
|
|
const supportExtraTags = [
|
|
{
|
|
text: '[name tag]',
|
|
search: '[]',
|
|
command: function () {
|
|
return '[name=' + personalInfo.name + ']'
|
|
}
|
|
},
|
|
{
|
|
text: '[time tag]',
|
|
search: '[]',
|
|
command: function () {
|
|
return '[time=' + moment().format('llll') + ']'
|
|
}
|
|
},
|
|
{
|
|
text: '[my color tag]',
|
|
search: '[]',
|
|
command: function () {
|
|
return '[color=' + personalInfo.color + ']'
|
|
}
|
|
},
|
|
{
|
|
text: '[random color tag]',
|
|
search: '[]',
|
|
command: function () {
|
|
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 () {
|
|
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')
|
|
})
|
|
}
|
|
|
|
// Re-enable nightmode
|
|
if (store.get('nightMode') || Cookies.get('nightMode')) {
|
|
$body.addClass('night')
|
|
ui.toolbar.night.addClass('active')
|
|
}
|
|
|
|
// showup
|
|
$().showUp('.navbar', {
|
|
upClass: 'navbar-hide',
|
|
downClass: 'navbar-show'
|
|
})
|
|
// tooltip
|
|
$('[data-toggle="tooltip"]').tooltip()
|
|
// shortcuts
|
|
// allow on all tags
|
|
key.filter = function (e) {
|
|
return true
|
|
}
|
|
key('ctrl+alt+e', function (e) {
|
|
changeMode(modeType.edit)
|
|
})
|
|
key('ctrl+alt+v', function (e) {
|
|
changeMode(modeType.view)
|
|
})
|
|
key('ctrl+alt+b', function (e) {
|
|
changeMode(modeType.both)
|
|
})
|
|
// toggle-dropdown
|
|
$(document).on('click', '.toggle-dropdown .dropdown-menu', function (e) {
|
|
e.stopPropagation()
|
|
})
|
|
})
|
|
// when page resize
|
|
$(window).resize(function () {
|
|
checkLayout()
|
|
checkEditorStyle()
|
|
checkTocStyle()
|
|
checkCursorMenu()
|
|
windowResize()
|
|
})
|
|
// when page unload
|
|
$(window).on('unload', function () {
|
|
// updateHistoryInner();
|
|
})
|
|
$(window).on('error', function () {
|
|
// setNeedRefresh();
|
|
})
|
|
|
|
setupSyncAreas(
|
|
ui.area.codemirrorScroll,
|
|
ui.area.view,
|
|
ui.area.markdown,
|
|
editor
|
|
)
|
|
|
|
function autoSyncscroll () {
|
|
if (editorHasFocus()) {
|
|
syncScrollToView()
|
|
} else {
|
|
syncScrollToEdit()
|
|
}
|
|
}
|
|
|
|
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: 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: 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 data = {
|
|
title: $('#snippetExportModalTitle').val(),
|
|
file_name: $('#snippetExportModalFileName').val(),
|
|
code: editor.getValue(),
|
|
visibility_level: $('#snippetExportModalVisibility').val(),
|
|
visibility:
|
|
$('#snippetExportModalVisibility').val() === '0'
|
|
? 'private'
|
|
: $('#snippetExportModalVisibility').val() === '10'
|
|
? 'internal'
|
|
: 'private'
|
|
}
|
|
|
|
if (
|
|
!data.title ||
|
|
!data.file_name ||
|
|
!data.code ||
|
|
!data.visibility_level ||
|
|
!$('#snippetExportModalProjects').val()
|
|
) { return }
|
|
$('#snippetExportModalLoading').show()
|
|
const fullURL =
|
|
baseURL +
|
|
'/api/' +
|
|
version +
|
|
'/projects/' +
|
|
$('#snippetExportModalProjects').val() +
|
|
'/snippets?access_token=' +
|
|
accesstoken
|
|
$.post(fullURL, data, function (ret) {
|
|
$('#snippetExportModalLoading').hide()
|
|
$('#snippetExportModal').modal('hide')
|
|
const redirect =
|
|
baseURL +
|
|
'/' +
|
|
$(
|
|
"#snippetExportModalProjects option[value='" +
|
|
$('#snippetExportModalProjects').val() +
|
|
"']"
|
|
).text() +
|
|
'/snippets/' +
|
|
ret.id
|
|
showMessageModal(
|
|
'<i class="fa fa-gitlab"></i> Export to Snippet',
|
|
'Export Successful!',
|
|
redirect,
|
|
'View Snippet Here',
|
|
true
|
|
)
|
|
})
|
|
})
|
|
|
|
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: 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 = ui.toolbar.night.hasClass('active')
|
|
if (isActive) {
|
|
$body.removeClass('night')
|
|
appState.nightMode = false
|
|
} else {
|
|
$body.addClass('night')
|
|
appState.nightMode = true
|
|
}
|
|
if (store.enabled) {
|
|
store.set('nightMode', !isActive)
|
|
} else {
|
|
Cookies.set('nightMode', !isActive, {
|
|
expires: 365,
|
|
sameSite: window.cookiePolicy
|
|
})
|
|
}
|
|
}
|
|
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: 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)
|
|
}
|
|
})
|