diff options
Diffstat (limited to 'public/js/lib')
-rw-r--r-- | public/js/lib/appState.js | 8 | ||||
-rw-r--r-- | public/js/lib/editor/index.js | 55 | ||||
-rw-r--r-- | public/js/lib/editor/statusbar.html | 41 | ||||
-rw-r--r-- | public/js/lib/modeType.js | 11 | ||||
-rw-r--r-- | public/js/lib/syncscroll.js | 371 |
5 files changed, 452 insertions, 34 deletions
diff --git a/public/js/lib/appState.js b/public/js/lib/appState.js new file mode 100644 index 00000000..fb8030e1 --- /dev/null +++ b/public/js/lib/appState.js @@ -0,0 +1,8 @@ +import modeType from './modeType' + +let state = { + syncscroll: true, + currentMode: modeType.view +} + +export default state diff --git a/public/js/lib/editor/index.js b/public/js/lib/editor/index.js index c807a17f..2991998b 100644 --- a/public/js/lib/editor/index.js +++ b/public/js/lib/editor/index.js @@ -1,5 +1,6 @@ import * as utils from './utils' import config from './config' +import statusBarTemplate from './statusbar.html' /* config section */ const isMac = CodeMirror.keyMap.default === CodeMirror.keyMap.macDefault @@ -118,6 +119,7 @@ export default class Editor { } } this.eventListeners = {} + this.config = config } on (event, cb) { @@ -132,40 +134,27 @@ export default class Editor { }) } - getStatusBarTemplate () { - return new Promise((resolve, reject) => { - $.get(window.serverurl + '/views/statusbar.html').done(template => { - this.statusBarTemplate = template - resolve() - }).fail(reject) - }) - } - addStatusBar () { - if (!this.statusBarTemplate) { - this.getStatusBarTemplate.then(this.addStatusBar) - } else { - this.statusBar = $(this.statusBarTemplate) - this.statusCursor = this.statusBar.find('.status-cursor > .status-line-column') - this.statusSelection = this.statusBar.find('.status-cursor > .status-selection') - this.statusFile = this.statusBar.find('.status-file') - this.statusIndicators = this.statusBar.find('.status-indicators') - this.statusIndent = this.statusBar.find('.status-indent') - this.statusKeymap = this.statusBar.find('.status-keymap') - this.statusLength = this.statusBar.find('.status-length') - this.statusTheme = this.statusBar.find('.status-theme') - this.statusSpellcheck = this.statusBar.find('.status-spellcheck') - this.statusPreferences = this.statusBar.find('.status-preferences') - this.statusPanel = this.editor.addPanel(this.statusBar[0], { - position: 'bottom' - }) + this.statusBar = $(statusBarTemplate) + this.statusCursor = this.statusBar.find('.status-cursor > .status-line-column') + this.statusSelection = this.statusBar.find('.status-cursor > .status-selection') + this.statusFile = this.statusBar.find('.status-file') + this.statusIndicators = this.statusBar.find('.status-indicators') + this.statusIndent = this.statusBar.find('.status-indent') + this.statusKeymap = this.statusBar.find('.status-keymap') + this.statusLength = this.statusBar.find('.status-length') + this.statusTheme = this.statusBar.find('.status-theme') + this.statusSpellcheck = this.statusBar.find('.status-spellcheck') + this.statusPreferences = this.statusBar.find('.status-preferences') + this.statusPanel = this.editor.addPanel(this.statusBar[0], { + position: 'bottom' + }) - this.setIndent() - this.setKeymap() - this.setTheme() - this.setSpellcheck() - this.setPreferences() - } + this.setIndent() + this.setKeymap() + this.setTheme() + this.setSpellcheck() + this.setPreferences() } updateStatusBar () { @@ -508,8 +497,6 @@ export default class Editor { placeholder: "← Start by entering a title here\n===\nVisit /features if you don't know what to do.\nHappy hacking :)" }) - this.getStatusBarTemplate() - return this.editor } diff --git a/public/js/lib/editor/statusbar.html b/public/js/lib/editor/statusbar.html new file mode 100644 index 00000000..24cbf6c2 --- /dev/null +++ b/public/js/lib/editor/statusbar.html @@ -0,0 +1,41 @@ +<div class="status-bar"> + <div class="status-info"> + <div class="status-cursor"> + <span class="status-line-column"></span> + <span class="status-selection"></span> + </div> + <div class="status-file"></div> + </div> + <div class="status-indicators"> + <div class="status-length"></div> + <div class="status-preferences dropup toggle-dropdown pull-right"> + <a id="preferencesLabel" class="ui-preferences-label text-uppercase" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" title="Click to change preferences"> + <i class="fa fa-wrench fa-fw"></i> + </a> + <ul class="dropdown-menu" aria-labelledby="preferencesLabel"> + <li class="ui-preferences-override-browser-keymap"><a><label>Allow override browser keymap <input type="checkbox"></label></a></li> + </ul> + </div> + <div class="status-keymap dropup pull-right"> + <a id="keymapLabel" class="ui-keymap-label text-uppercase" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" title="Click to change keymap"> + Sublime + </a> + <ul class="dropdown-menu" aria-labelledby="keymapLabel"> + <li class="ui-keymap-sublime"><a>Sublime</a></li> + <li class="ui-keymap-emacs"><a>Emacs</a></li> + <li class="ui-keymap-vim"><a>Vim</a></li> + </ul> + </div> + <div class="status-indent"> + <div class="indent-type" title="Click to switch indentation type">Spaces:</div> + <div class="indent-width-label" title="Click to change indentation size">4</div> + <input class="indent-width-input hidden" type="number" min="1" max="10" maxlength="2" size="2"> + </div> + <div class="status-theme"> + <a class="ui-theme-toggle" title="Toggle editor theme"><i class="fa fa-sun-o fa-fw"></i></a> + </div> + <div class="status-spellcheck"> + <a class="ui-spellcheck-toggle" title="Toggle spellcheck"><i class="fa fa-check fa-fw"></i></a> + </div> + </div> +</div> diff --git a/public/js/lib/modeType.js b/public/js/lib/modeType.js new file mode 100644 index 00000000..f3212105 --- /dev/null +++ b/public/js/lib/modeType.js @@ -0,0 +1,11 @@ +export default { + edit: { + name: 'edit' + }, + view: { + name: 'view' + }, + both: { + name: 'both' + } +} diff --git a/public/js/lib/syncscroll.js b/public/js/lib/syncscroll.js new file mode 100644 index 00000000..cee317ea --- /dev/null +++ b/public/js/lib/syncscroll.js @@ -0,0 +1,371 @@ +/* eslint-env browser, jquery */ +/* global _ */ +// Inject line numbers for sync scroll. + +import markdownitContainer from 'markdown-it-container' + +import { md } from '../extra' +import modeType from './modeType' +import appState from './appState' + +function addPart (tokens, idx) { + if (tokens[idx].map && tokens[idx].level === 0) { + const startline = tokens[idx].map[0] + 1 + const endline = tokens[idx].map[1] + tokens[idx].attrJoin('class', 'part') + tokens[idx].attrJoin('data-startline', startline) + tokens[idx].attrJoin('data-endline', endline) + } +} + +md.renderer.rules.blockquote_open = function (tokens, idx, options, env, self) { + tokens[idx].attrJoin('class', 'raw') + addPart(tokens, idx) + return self.renderToken(...arguments) +} +md.renderer.rules.table_open = function (tokens, idx, options, env, self) { + addPart(tokens, idx) + return self.renderToken(...arguments) +} +md.renderer.rules.bullet_list_open = function (tokens, idx, options, env, self) { + addPart(tokens, idx) + return self.renderToken(...arguments) +} +md.renderer.rules.list_item_open = function (tokens, idx, options, env, self) { + tokens[idx].attrJoin('class', 'raw') + if (tokens[idx].map) { + const startline = tokens[idx].map[0] + 1 + const endline = tokens[idx].map[1] + tokens[idx].attrJoin('data-startline', startline) + tokens[idx].attrJoin('data-endline', endline) + } + return self.renderToken(...arguments) +} +md.renderer.rules.ordered_list_open = function (tokens, idx, options, env, self) { + addPart(tokens, idx) + return self.renderToken(...arguments) +} +md.renderer.rules.link_open = function (tokens, idx, options, env, self) { + addPart(tokens, idx) + return self.renderToken(...arguments) +} +md.renderer.rules.paragraph_open = function (tokens, idx, options, env, self) { + addPart(tokens, idx) + return self.renderToken(...arguments) +} +md.renderer.rules.heading_open = function (tokens, idx, options, env, self) { + tokens[idx].attrJoin('class', 'raw') + addPart(tokens, idx) + return self.renderToken(...arguments) +} +md.renderer.rules.fence = (tokens, idx, options, env, self) => { + const token = tokens[idx] + const info = token.info ? md.utils.unescapeAll(token.info).trim() : '' + let langName = '' + let highlighted + + if (info) { + langName = info.split(/\s+/g)[0] + if (/!$/.test(info)) token.attrJoin('class', 'wrap') + token.attrJoin('class', options.langPrefix + langName.replace(/=$|=\d+$|=\+$|!$|=!/, '')) + token.attrJoin('class', 'hljs') + token.attrJoin('class', 'raw') + } + + if (options.highlight) { + highlighted = options.highlight(token.content, langName) || md.utils.escapeHtml(token.content) + } else { + highlighted = md.utils.escapeHtml(token.content) + } + + if (highlighted.indexOf('<pre') === 0) { + return `${highlighted}\n` + } + + if (tokens[idx].map && tokens[idx].level === 0) { + const startline = tokens[idx].map[0] + 1 + const endline = tokens[idx].map[1] + return `<pre class="part" data-startline="${startline}" data-endline="${endline}"><code${self.renderAttrs(token)}>${highlighted}</code></pre>\n` + } + + return `<pre><code${self.renderAttrs(token)}>${highlighted}</code></pre>\n` +} +md.renderer.rules.code_block = (tokens, idx, options, env, self) => { + if (tokens[idx].map && tokens[idx].level === 0) { + const startline = tokens[idx].map[0] + 1 + const endline = tokens[idx].map[1] + return `<pre class="part" data-startline="${startline}" data-endline="${endline}"><code>${md.utils.escapeHtml(tokens[idx].content)}</code></pre>\n` + } + return `<pre><code>${md.utils.escapeHtml(tokens[idx].content)}</code></pre>\n` +} +function renderContainer (tokens, idx, options, env, self) { + tokens[idx].attrJoin('role', 'alert') + tokens[idx].attrJoin('class', 'alert') + tokens[idx].attrJoin('class', `alert-${tokens[idx].info.trim()}`) + addPart(tokens, idx) + return self.renderToken(...arguments) +} + +md.use(markdownitContainer, 'success', { render: renderContainer }) +md.use(markdownitContainer, 'info', { render: renderContainer }) +md.use(markdownitContainer, 'warning', { render: renderContainer }) +md.use(markdownitContainer, 'danger', { render: renderContainer }) + +window.preventSyncScrollToEdit = false +window.preventSyncScrollToView = false + +const editScrollThrottle = 5 +const viewScrollThrottle = 5 +const buildMapThrottle = 100 + +let viewScrolling = false +let editScrolling = false + +let editArea = null +let viewArea = null +let markdownArea = null + +let editor + +export function setupSyncAreas (edit, view, markdown, _editor) { + editArea = edit + viewArea = view + markdownArea = markdown + + editor = _editor + + editArea.on('scroll', _.throttle(syncScrollToView, editScrollThrottle)) + viewArea.on('scroll', _.throttle(syncScrollToEdit, viewScrollThrottle)) +} + +let scrollMap, lineHeightMap, viewTop, viewBottom + +export function clearMap () { + scrollMap = null + lineHeightMap = null + viewTop = null + viewBottom = null +} +window.viewAjaxCallback = clearMap + +const buildMap = _.throttle(buildMapInner, buildMapThrottle) + +// Build offsets for each line (lines can be wrapped) +// That's a bit dirty to process each line everytime, but ok for demo. +// Optimizations are required only for big texts. +function buildMapInner (callback) { + if (!viewArea || !markdownArea) return + let i, offset, nonEmptyList, pos, a, b, _lineHeightMap, linesCount, acc, _scrollMap + + offset = viewArea.scrollTop() - viewArea.offset().top + _scrollMap = [] + nonEmptyList = [] + _lineHeightMap = [] + viewTop = 0 + viewBottom = viewArea[0].scrollHeight - viewArea.height() + + acc = 0 + const lines = editor.getValue().split('\n') + const lineHeight = editor.defaultTextHeight() + for (i = 0; i < lines.length; i++) { + const str = lines[i] + + _lineHeightMap.push(acc) + + if (str.length === 0) { + acc++ + continue + } + + const h = editor.heightAtLine(i + 1) - editor.heightAtLine(i) + acc += Math.round(h / lineHeight) + } + _lineHeightMap.push(acc) + linesCount = acc + + for (i = 0; i < linesCount; i++) { + _scrollMap.push(-1) + } + + nonEmptyList.push(0) + // make the first line go top + _scrollMap[0] = viewTop + + const parts = markdownArea.find('.part').toArray() + for (i = 0; i < parts.length; i++) { + const $el = $(parts[i]) + let t = $el.attr('data-startline') - 1 + if (t === '') { + return + } + t = _lineHeightMap[t] + if (t !== 0 && t !== nonEmptyList[nonEmptyList.length - 1]) { + nonEmptyList.push(t) + } + _scrollMap[t] = Math.round($el.offset().top + offset - 10) + } + + nonEmptyList.push(linesCount) + _scrollMap[linesCount] = viewArea[0].scrollHeight + + pos = 0 + for (i = 1; i < linesCount; i++) { + if (_scrollMap[i] !== -1) { + pos++ + continue + } + + a = nonEmptyList[pos] + b = nonEmptyList[pos + 1] + _scrollMap[i] = Math.round((_scrollMap[b] * (i - a) + _scrollMap[a] * (b - i)) / (b - a)) + } + + _scrollMap[0] = 0 + + scrollMap = _scrollMap + lineHeightMap = _lineHeightMap + + if (window.loaded && callback) callback() +} + +// sync view scroll progress to edit +let viewScrollingTimer = null + +export function syncScrollToEdit (event, preventAnimate) { + if (appState.currentMode !== modeType.both || !appState.syncscroll || !editArea) return + if (window.preventSyncScrollToEdit) { + if (typeof window.preventSyncScrollToEdit === 'number') { + window.preventSyncScrollToEdit-- + } else { + window.preventSyncScrollToEdit = false + } + return + } + if (!scrollMap || !lineHeightMap) { + buildMap(() => { + syncScrollToEdit(event, preventAnimate) + }) + return + } + if (editScrolling) return + + const scrollTop = viewArea[0].scrollTop + let lineIndex = 0 + for (let i = 0, l = scrollMap.length; i < l; i++) { + if (scrollMap[i] > scrollTop) { + break + } else { + lineIndex = i + } + } + let lineNo = 0 + let lineDiff = 0 + for (let i = 0, l = lineHeightMap.length; i < l; i++) { + if (lineHeightMap[i] > lineIndex) { + break + } else { + lineNo = lineHeightMap[i] + lineDiff = lineHeightMap[i + 1] - lineNo + } + } + + let posTo = 0 + let topDiffPercent = 0 + let posToNextDiff = 0 + const scrollInfo = editor.getScrollInfo() + const textHeight = editor.defaultTextHeight() + const preLastLineHeight = scrollInfo.height - scrollInfo.clientHeight - textHeight + const preLastLineNo = Math.round(preLastLineHeight / textHeight) + const preLastLinePos = scrollMap[preLastLineNo] + + if (scrollInfo.height > scrollInfo.clientHeight && scrollTop >= preLastLinePos) { + posTo = preLastLineHeight + topDiffPercent = (scrollTop - preLastLinePos) / (viewBottom - preLastLinePos) + posToNextDiff = textHeight * topDiffPercent + posTo += Math.ceil(posToNextDiff) + } else { + posTo = lineNo * textHeight + topDiffPercent = (scrollTop - scrollMap[lineNo]) / (scrollMap[lineNo + lineDiff] - scrollMap[lineNo]) + posToNextDiff = textHeight * lineDiff * topDiffPercent + posTo += Math.ceil(posToNextDiff) + } + + if (preventAnimate) { + editArea.scrollTop(posTo) + } else { + const posDiff = Math.abs(scrollInfo.top - posTo) + var duration = posDiff / 50 + duration = duration >= 100 ? duration : 100 + editArea.stop(true, true).animate({ + scrollTop: posTo + }, duration, 'linear') + } + + viewScrolling = true + clearTimeout(viewScrollingTimer) + viewScrollingTimer = setTimeout(viewScrollingTimeoutInner, duration * 1.5) +} + +function viewScrollingTimeoutInner () { + viewScrolling = false +} + +// sync edit scroll progress to view +let editScrollingTimer = null + +export function syncScrollToView (event, preventAnimate) { + if (appState.currentMode !== modeType.both || !appState.syncscroll || !viewArea) return + if (window.preventSyncScrollToView) { + if (typeof preventSyncScrollToView === 'number') { + window.preventSyncScrollToView-- + } else { + window.preventSyncScrollToView = false + } + return + } + if (!scrollMap || !lineHeightMap) { + buildMap(() => { + syncScrollToView(event, preventAnimate) + }) + return + } + if (viewScrolling) return + + let lineNo, posTo + let topDiffPercent, posToNextDiff + const scrollInfo = editor.getScrollInfo() + const textHeight = editor.defaultTextHeight() + lineNo = Math.floor(scrollInfo.top / textHeight) + // if reach the last line, will start lerp to the bottom + const diffToBottom = (scrollInfo.top + scrollInfo.clientHeight) - (scrollInfo.height - textHeight) + if (scrollInfo.height > scrollInfo.clientHeight && diffToBottom > 0) { + topDiffPercent = diffToBottom / textHeight + posTo = scrollMap[lineNo + 1] + posToNextDiff = (viewBottom - posTo) * topDiffPercent + posTo += Math.floor(posToNextDiff) + } else { + topDiffPercent = (scrollInfo.top % textHeight) / textHeight + posTo = scrollMap[lineNo] + posToNextDiff = (scrollMap[lineNo + 1] - posTo) * topDiffPercent + posTo += Math.floor(posToNextDiff) + } + + if (preventAnimate) { + viewArea.scrollTop(posTo) + } else { + const posDiff = Math.abs(viewArea.scrollTop() - posTo) + var duration = posDiff / 50 + duration = duration >= 100 ? duration : 100 + viewArea.stop(true, true).animate({ + scrollTop: posTo + }, duration, 'linear') + } + + editScrolling = true + clearTimeout(editScrollingTimer) + editScrollingTimer = setTimeout(editScrollingTimeoutInner, duration * 1.5) +} + +function editScrollingTimeoutInner () { + editScrolling = false +} |