UX: Improve editor and preview scroll syncing.

This commit is contained in:
Guo Xiang Tan 2017-11-23 16:55:00 +08:00
parent e61629ed84
commit 486016acea
3 changed files with 198 additions and 20 deletions

View File

@ -17,11 +17,18 @@ import { tinyAvatar,
} from 'discourse/lib/utilities';
import { cacheShortUploadUrl, resolveAllShortUrls } from 'pretty-text/image-short-url';
const REBUILD_SCROLL_MAP_EVENTS = [
'composer:resized',
'composer:typed-reply'
];
export default Ember.Component.extend({
classNameBindings: ['showToolbar:toolbar-visible', ':wmd-controls'],
uploadProgress: 0,
_xhr: null,
shouldBuildScrollMap: true,
scrollMap: null,
@computed
uploadPlaceholder() {
@ -67,6 +74,8 @@ export default Ember.Component.extend({
_composerEditorInit() {
const topicId = this.get('topic.id');
const $input = this.$('.d-editor-input');
const $preview = this.$('.d-editor-preview');
$input.autocomplete({
template: findRawTemplate('user-selector-autocomplete'),
dataSource: term => userSearch({
@ -78,7 +87,7 @@ export default Ember.Component.extend({
transformComplete: v => v.username || v.name
});
$input.on('scroll', () => Ember.run.throttle(this, this._syncEditorAndPreviewScroll, 20));
this._initInputPreviewSync($input, $preview);
// Focus on the body unless we have a title
if (!this.get('composer.canEditTitle') && !this.capabilities.isIOS) {
@ -118,29 +127,146 @@ export default Ember.Component.extend({
}
},
_syncEditorAndPreviewScroll() {
const $input = this.$('.d-editor-input');
if (!$input) { return; }
_resetShouldBuildScrollMap() {
this.set('shouldBuildScrollMap', true);
},
const $preview = this.$('.d-editor-preview');
_initInputPreviewSync($input, $preview) {
REBUILD_SCROLL_MAP_EVENTS.forEach(event => {
this.appEvents.on(event, this, this._resetShouldBuildScrollMap);
});
if ($input.scrollTop() === 0) {
$preview.scrollTop(0);
$input.on('touchstart mouseenter', () => {
if (!$preview.is(":visible")) return;
$preview.off('scroll');
if (this.get('shouldBuildScrollMap')) {
this.set('scrollMap', this._buildScrollMap($input, $preview));
this.set('shouldBuildScrollMap', false);
}
$input.on('scroll', () => {
Ember.run.throttle(this, this._syncEditorAndPreviewScroll, $input, $preview, this.get('scrollMap'), 20);
});
});
$preview.on('touchstart mouseenter', () => {
$input.off('scroll');
if (this.get('shouldBuildScrollMap')) {
this.set('scrollMap', this._buildScrollMap($input, $preview));
this.set('shouldBuildScrollMap', false);
}
$preview.on('scroll', () => {
Ember.run.throttle(this, this._syncPreviewAndEditorScroll, $input, $preview, this.get('scrollMap'), 20);
});
});
},
_teardownInputPreviewSync() {
[this.$('.d-editor-input'), this.$('.d-editor-preview')].forEach($element => {
$element.off("mouseenter touchstart");
$element.off("scroll");
});
REBUILD_SCROLL_MAP_EVENTS.forEach(event => {
this.appEvents.off(event, this, this._resetShouldBuildScrollMap);
});;
},
// Adapted from https://github.com/markdown-it/markdown-it.github.io
_buildScrollMap($input, $preview) {
let sourceLikeDiv = $('<div />').css({
position: 'absolute',
height: 'auto',
visibility: 'hidden',
width: $input[0].clientWidth,
'font-size': $input.css('font-size'),
'font-family': $input.css('font-family'),
'line-height': $input.css('line-height'),
'white-space': $input.css('white-space')
}).appendTo('body');
const linesMap = [];
let numberOfLines = 0;
$input.val().split('\n').forEach(text => {
linesMap.push(numberOfLines);
if (text.length === 0) {
numberOfLines++;
} else {
sourceLikeDiv.text(text);
let height;
let lineHeight;
height = parseFloat(sourceLikeDiv.css('height'));
lineHeight = parseFloat(sourceLikeDiv.css('line-height'));
numberOfLines += Math.round(height / lineHeight);
}
});
linesMap.push(numberOfLines);
sourceLikeDiv.remove();
const previewOffsetTop = $preview.offset().top;
const offset = $preview.scrollTop() - previewOffsetTop - ($input.offset().top - previewOffsetTop);
const nonEmptyList = [];
const scrollMap = Array(numberOfLines).fill(-1);
nonEmptyList.push(0);
scrollMap[0] = 0;
$preview.find('.preview-sync-line').each((_, element) => {
let $element = $(element);
let lineNumber = $element.data('line-number');
let linesToTop = linesMap[lineNumber];
if (linesToTop !== 0) { nonEmptyList.push(linesToTop); }
scrollMap[linesToTop] = Math.round($element.offset().top + offset);
});
nonEmptyList.push(numberOfLines);
scrollMap[numberOfLines] = $preview[0].scrollHeight;
let position = 0;
_.times(numberOfLines, currentLineNumber => {
if (scrollMap[currentLineNumber] !== -1) {
position++;
return;
}
const inputHeight = $input[0].scrollHeight;
const previewHeight = $preview[0].scrollHeight;
if (($input.height() + $input.scrollTop() + 100) > inputHeight) {
// cheat, special case for bottom
$preview.scrollTop(previewHeight);
return;
}
let top = nonEmptyList[position];
let bottom = nonEmptyList[position + 1];
const scrollPosition = $input.scrollTop();
const factor = previewHeight / inputHeight;
const desired = scrollPosition * factor;
$preview.scrollTop(desired + 50);
scrollMap[currentLineNumber] =
((
scrollMap[bottom] * (currentLineNumber - top) +
scrollMap[top] * (bottom - currentLineNumber)
) / (bottom - top)).toFixed(2);
});
return scrollMap;
},
_syncEditorAndPreviewScroll($input, $preview, scrollMap) {
const lineHeight = parseFloat($input.css('line-height'));
const lineNumber = Math.floor($input.scrollTop() / lineHeight);
$preview.stop(true).animate({
scrollTop: scrollMap[lineNumber]
}, 100, 'linear');
},
_syncPreviewAndEditorScroll($input, $preview, scrollMap) {
if (scrollMap.length < 1) return;
const scrollTop = $preview.scrollTop();
const lineHeight = parseFloat($input.css('line-height'));
$input.stop(true).animate({
scrollTop: lineHeight * scrollMap.findIndex(offset => offset > scrollTop)
}, 100, 'linear');
},
_renderUnseenMentions($preview, unseen) {
@ -462,6 +588,8 @@ export default Ember.Component.extend({
Ember.run.later(() => this.appEvents.trigger("composer:closed"), 400);
});
this._teardownInputPreviewSync();
if (this.site.mobileView) {
$(window).off('resize.composer-popup-menu');
}

View File

@ -15,3 +15,4 @@
//= require ./pretty-text/engines/discourse-markdown/html-img
//= require ./pretty-text/engines/discourse-markdown/text-post-process
//= require ./pretty-text/engines/discourse-markdown/image-protocol
//= require ./pretty-text/engines/discourse-markdown/inject-line-number

View File

@ -0,0 +1,49 @@
export function setup(helper) {
if (helper.getOptions().previewing) {
helper.whiteList([
'p.preview-sync-line',
'p[data-line-number]',
'h1.preview-sync-line',
'h1[data-line-number]',
'h2.preview-sync-line',
'h2[data-line-number]',
'h3.preview-sync-line',
'h3[data-line-number]',
'h4.preview-sync-line',
'h4[data-line-number]',
'h5.preview-sync-line',
'h5[data-line-number]',
'h6.preview-sync-line',
'h6[data-line-number]',
'blockquote.preview-sync-line',
'blockquote[data-line-number]',
'hr.preview-sync-line',
'hr[data-line-number]',
'ul.preview-sync-line',
'ul[data-line-number]',
'ol.preview-sync-line',
'ol[data-line-number]',
]);
helper.registerPlugin(md => {
const injectLineNumber = (tokens, index, options, env, self) => {
let line;
if (tokens[index].map && tokens[index].level === 0) {
line = tokens[index].map[0];
tokens[index].attrJoin('class', 'preview-sync-line');
tokens[index].attrSet('data-line-number', String(line));
}
return self.renderToken(tokens, index, options, env, self);
};
md.renderer.rules.paragraph_open = injectLineNumber;
md.renderer.rules.heading_open = injectLineNumber;
md.renderer.rules.blockquote_open = injectLineNumber;
md.renderer.rules.hr = injectLineNumber;
md.renderer.rules.ordered_list_open = injectLineNumber;
md.renderer.rules.bullet_list_open = injectLineNumber;
});
}
}