Synced editor scrolling PoC.

This commit is contained in:
Wojciech Zawistowski 2014-03-27 10:18:48 -04:00
parent 7baa8ea0af
commit 297c25ca1f
6 changed files with 195 additions and 47 deletions

View file

@ -411,7 +411,7 @@ Discourse.Composer = Discourse.Model.extend({
raw: this.get('reply'), raw: this.get('reply'),
editReason: opts.editReason, editReason: opts.editReason,
imageSizes: opts.imageSizes, imageSizes: opts.imageSizes,
cooked: $('#wmd-preview').html() cooked: this.getCookedHtml()
}); });
this.set('composeState', CLOSED); this.set('composeState', CLOSED);
@ -448,7 +448,7 @@ Discourse.Composer = Discourse.Model.extend({
topic_id: this.get('topic.id'), topic_id: this.get('topic.id'),
reply_to_post_number: post ? post.get('post_number') : null, reply_to_post_number: post ? post.get('post_number') : null,
imageSizes: opts.imageSizes, imageSizes: opts.imageSizes,
cooked: $('#wmd-preview').html(), cooked: this.getCookedHtml(),
reply_count: 0, reply_count: 0,
display_username: currentUser.get('name'), display_username: currentUser.get('name'),
username: currentUser.get('username'), username: currentUser.get('username'),
@ -534,6 +534,10 @@ Discourse.Composer = Discourse.Model.extend({
}); });
}, },
getCookedHtml: function() {
return $('#wmd-preview').html().replace(/<span class="marker"><\/span>/g, '');
},
saveDraft: function() { saveDraft: function() {
// Do not save when drafts are disabled // Do not save when drafts are disabled
if (this.get('disableDrafts')) return; if (this.get('disableDrafts')) return;

View file

@ -56,6 +56,7 @@
<div class='wmd-controls'> <div class='wmd-controls'>
<div class='textarea-wrapper'> <div class='textarea-wrapper'>
<div class='wmd-button-bar' id='wmd-button-bar'></div> <div class='wmd-button-bar' id='wmd-button-bar'></div>
<div id='wmd-preview-scroller'></div>
{{view Discourse.NotifyingTextArea parentBinding="view" tabindex="3" valueBinding="model.reply" id="wmd-input" placeholderKey="composer.reply_placeholder"}} {{view Discourse.NotifyingTextArea parentBinding="view" tabindex="3" valueBinding="model.reply" id="wmd-input" placeholderKey="composer.reply_placeholder"}}
{{popupInputTip validation=view.replyValidation shownAt=view.showReplyTip}} {{popupInputTip validation=view.replyValidation shownAt=view.showReplyTip}}
</div> </div>

View file

@ -52,14 +52,6 @@ Discourse.ComposerView = Discourse.View.extend(Ember.Evented, {
refreshPreview: Discourse.debounce(function() { refreshPreview: Discourse.debounce(function() {
if (this.editor) { if (this.editor) {
this.editor.refreshPreview(); this.editor.refreshPreview();
// if the caret is on the last line ensure preview scrolled to bottom
var caretPosition = Discourse.Utilities.caretPosition(this.wmdInput[0]);
if (!this.wmdInput.val().substring(caretPosition).match(/\n/)) {
var $wmdPreview = $('#wmd-preview');
if ($wmdPreview.is(':visible')) {
$wmdPreview.scrollTop($wmdPreview[0].scrollHeight);
}
}
} }
}, 30), }, 30),

View file

@ -474,7 +474,7 @@ div.ac-wrap {
margin-top: 0 !important; margin-top: 0 !important;
} }
#wmd-input, #wmd-preview { #wmd-input, #wmd-preview-scroller, #wmd-preview {
@include box-sizing(border-box); @include box-sizing(border-box);
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -488,8 +488,11 @@ div.ac-wrap {
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
margin: 30px 0 10px; margin: 30px 0 10px;
} }
p {
margin-top: 19px;
}
} }
#wmd-input { #wmd-input, #wmd-preview-scroller {
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
@ -502,6 +505,18 @@ div.ac-wrap {
@include border-radius-all(0); @include border-radius-all(0);
transition: none; transition: none;
} }
#wmd-preview-scroller {
font-size: 13px;
line-height: 18px;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-weight: normal;
overflow: scroll;
visibility: hidden;
.marker, .caret {
display: inline-block;
vertical-align: top;
}
}
.textarea-wrapper, .preview-wrapper { .textarea-wrapper, .preview-wrapper {
position: relative; position: relative;
@include box-sizing(border-box); @include box-sizing(border-box);

View file

@ -344,7 +344,6 @@ div.ac-wrap {
font-size: 16px; font-size: 16px;
} }
} }
#reply-control .wmd-controls #wmd-input {font-size: 16px;}
#reply-control.edit-title.private-message { #reply-control.edit-title.private-message {
.wmd-controls { .wmd-controls {
@ -387,7 +386,7 @@ div.ac-wrap {
margin-top: 0 !important; margin-top: 0 !important;
} }
#wmd-input, #wmd-preview { #wmd-input, #wmd-preview-scroller, #wmd-preview {
@include box-sizing(border-box); @include box-sizing(border-box);
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -401,8 +400,11 @@ div.ac-wrap {
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
margin: 30px 0 10px; margin: 30px 0 10px;
} }
p {
margin-top: 19px;
}
} }
#wmd-input { #wmd-input, #wmd-preview-scroller {
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
@ -413,6 +415,17 @@ div.ac-wrap {
border-top: 36px solid transparent; border-top: 36px solid transparent;
@include border-radius-all(0); @include border-radius-all(0);
transition: none; transition: none;
font-size: 16px;
}
#wmd-preview-scroller {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-weight: normal;
overflow: scroll;
visibility: hidden;
.marker, .caret {
display: inline-block;
vertical-align: top;
}
} }
.textarea-wrapper, .preview-wrapper { .textarea-wrapper, .preview-wrapper {
position: relative; position: relative;

View file

@ -330,6 +330,7 @@
function PanelCollection(postfix) { function PanelCollection(postfix) {
this.buttonBar = doc.getElementById("wmd-button-bar" + postfix); this.buttonBar = doc.getElementById("wmd-button-bar" + postfix);
this.preview = doc.getElementById("wmd-preview" + postfix); this.preview = doc.getElementById("wmd-preview" + postfix);
this.previewScroller = doc.getElementById("wmd-preview-scroller" + postfix);
this.input = doc.getElementById("wmd-input" + postfix); this.input = doc.getElementById("wmd-input" + postfix);
}; };
@ -861,6 +862,113 @@
var maxDelay = 3000; var maxDelay = 3000;
var startType = "delayed"; // The other legal value is "manual" var startType = "delayed"; // The other legal value is "manual"
var paneContentHeight = function(pane) {
var $pane = $(pane);
var paneVerticalPadding = parseInt($pane.css("padding-top")) + parseInt($pane.css("padding-bottom"));
return pane.scrollHeight - paneVerticalPadding;
};
var prevScrollPosition = $(panels.input).scrollTop();
var caretMarkerPosition = 0;
var markerPositions = {
scroller: [0, paneContentHeight(panels.previewScroller)],
preview: [0, paneContentHeight(panels.preview)]
};
var getCaretPosition = function() {
return Discourse.Utilities.caretPosition(panels.input);
};
var cacheCaretMarkerPosition = function() {
caretMarkerPosition = $(panels.previewScroller).find(".caret").position().top;
};
var cachePaneMarkerPositions = function(cacheName, pane) {
var $pane = $(pane);
var paneScrollPosition = $pane.scrollTop();
var panePaddingTop = parseInt($pane.css("padding-top"));
markerPositions[cacheName] = [0];
$(pane).find(".marker").each(function () {
var markerPosition = $(this).position().top + paneScrollPosition - panePaddingTop;
markerPositions[cacheName].push(markerPosition);
});
markerPositions[cacheName].push(paneContentHeight(pane));
};
var cacheMarkerPositions = function() {
cachePaneMarkerPositions("scroller", panels.previewScroller);
cachePaneMarkerPositions("preview", panels.preview);
};
var getMarkerPositions = function(syncPosition) {
var startMarkerIndex = 0;
var endMarkerIndex = markerPositions.scroller.length - 1;
for (var index = startMarkerIndex + 1; index < endMarkerIndex; index += 1) {
if (markerPositions.scroller[index] > syncPosition) {
endMarkerIndex = index;
break;
}
startMarkerIndex = index;
}
return {
scrollerStart: markerPositions.scroller[startMarkerIndex],
scrollerEnd: markerPositions.scroller[endMarkerIndex],
previewStart: markerPositions.preview[startMarkerIndex],
previewEnd: markerPositions.preview[endMarkerIndex]
};
};
var detectScrollDown = function(currentPosition, previousPosition) {
return (currentPosition - previousPosition >= 0);
};
var getRatio = function(positions) {
return (positions.previewEnd - positions.previewStart) / (positions.scrollerEnd - positions.scrollerStart);
};
var syncScroll = function(isEdit) {
var scrollPosition = $(panels.input).scrollTop();
var isScrollDown = (scrollPosition - prevScrollPosition >= 0);
prevScrollPosition = scrollPosition;
var inputBaseline;
var previewBaseline;
var threshold;
if (isEdit) {
inputBaseline = caretMarkerPosition;
previewBaseline = ($(panels.preview).height() * (caretMarkerPosition - scrollPosition) / $(panels.input).height());
threshold = 20;
} else if (isScrollDown) {
inputBaseline = scrollPosition + $(panels.input).height();
previewBaseline = $(panels.preview).height();
threshold = 0;
} else {
inputBaseline = scrollPosition;
previewBaseline = 0;
threshold = 0;
}
var positions = getMarkerPositions(inputBaseline);
var ratio = getRatio(positions);
var newPreviewScrollPosition = positions.previewStart - previewBaseline + (inputBaseline - positions.scrollerStart) * ratio;
if (threshold == 0 || Math.abs(newPreviewScrollPosition - $(panels.preview).scrollTop()) >= threshold) {
$(panels.preview).scrollTop(newPreviewScrollPosition);
}
};
var setupScrollSync = function() {
$(panels.input).scroll(function() {
Ember.run.throttle(null, syncScroll, 16);
});
};
// Adds event listeners to elements // Adds event listeners to elements
var setupEvents = function (inputElem, listener) { var setupEvents = function (inputElem, listener) {
@ -909,14 +1017,33 @@
var prevTime = new Date().getTime(); var prevTime = new Date().getTime();
text = converter.makeHtml(text); var caretPosition = getCaretPosition();
text = text.slice(0, caretPosition) + '~~caret~~' + text.slice(caretPosition);
text = text.replace(/(\n|\r|\r\n)(\n|\r|\r\n)+/g, "$&~~marker~~$1$1");
previewText = converter.makeHtml(text.replace('~~caret~~', ''))
.replace(/<p>~~marker~~<\/p>/g, '<span class="marker"></span>')
.replace(/~~marker~~/g, '<span class="marker"></span>');
previewScrollerText = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(\n|\r|\r\n)/g, '<br>')
.replace('~~caret~~', '<span class="caret"></span>')
.replace(/~~marker~~<br><br>/g, '<span class="marker"></span>');
// Calculate the processing time of the HTML creation. // Calculate the processing time of the HTML creation.
// It's used as the delay time in the event listener. // It's used as the delay time in the event listener.
var currTime = new Date().getTime(); var currTime = new Date().getTime();
elapsedTime = currTime - prevTime; elapsedTime = currTime - prevTime;
pushPreviewHtml(text); Ember.run(function() {
pushPreviewHtml(previewText, previewScrollerText);
cacheMarkerPositions();
cacheCaretMarkerPosition();
syncScroll(true);
});
}; };
// makePreviewHtml = window.probes.measure(makePreviewHtml, { // makePreviewHtml = window.probes.measure(makePreviewHtml, {
@ -990,12 +1117,6 @@
return panel.scrollTop / (panel.scrollHeight - panel.clientHeight); return panel.scrollTop / (panel.scrollHeight - panel.clientHeight);
}; };
var setPanelScrollTops = function () {
if (panels.preview) {
panels.preview.scrollTop = (panels.preview.scrollHeight - panels.preview.clientHeight) * getScaleFactor(panels.preview);
}
};
this.refresh = function (requiresRefresh) { this.refresh = function (requiresRefresh) {
if (requiresRefresh) { if (requiresRefresh) {
oldInputText = ""; oldInputText = "";
@ -1015,49 +1136,52 @@
// IE doesn't let you use innerHTML if the element is contained somewhere in a table // IE doesn't let you use innerHTML if the element is contained somewhere in a table
// (which is the case for inline editing) -- in that case, detach the element, set the // (which is the case for inline editing) -- in that case, detach the element, set the
// value, and reattach. Yes, that *is* ridiculous. // value, and reattach. Yes, that *is* ridiculous.
var ieSafePreviewSet = function (text) { var ieSafePreviewSet = function (previewText, previewScrollerText) {
var preview = panels.preview; var ieSafeSet = function(panel, text) {
var parent = preview.parentNode; var parent = panel.parentNode;
var sibling = preview.nextSibling; var sibling = panel.nextSibling;
parent.removeChild(preview); parent.removeChild(panel);
preview.innerHTML = text; panel.innerHTML = text;
if (!sibling) if (!sibling)
parent.appendChild(preview); parent.appendChild(panel);
else else
parent.insertBefore(preview, sibling); parent.insertBefore(panel, sibling);
};
ieSafeSet(panels.preview, previewText);
ieSafeSet(panels.previewScroller, previewScrollerText);
} }
var nonSuckyBrowserPreviewSet = function (text) { var nonSuckyBrowserPreviewSet = function (previewText, previewScrollerText) {
panels.preview.innerHTML = text; panels.preview.innerHTML = previewText;
panels.previewScroller.innerHTML = previewScrollerText;
} }
var previewSetter; var previewSetter;
var previewSet = function (text) { var previewSet = function (previewText, previewScrollerText) {
if (previewSetter) if (previewSetter)
return previewSetter(text); return previewSetter(previewText, previewScrollerText);
try { try {
nonSuckyBrowserPreviewSet(text); nonSuckyBrowserPreviewSet(previewText, previewScrollerText);
previewSetter = nonSuckyBrowserPreviewSet; previewSetter = nonSuckyBrowserPreviewSet;
} catch (e) { } catch (e) {
previewSetter = ieSafePreviewSet; previewSetter = ieSafePreviewSet;
previewSetter(text); previewSetter(previewText, previewScrollerText);
} }
}; };
var pushPreviewHtml = function (text) { var pushPreviewHtml = function (previewText, previewScrollerText) {
var emptyTop = position.getTop(panels.input) - getDocScrollTop(); var emptyTop = position.getTop(panels.input) - getDocScrollTop();
if (panels.preview) { if (panels.preview) {
previewSet(text); previewSet(previewText, previewScrollerText);
previewRefreshCallback(); previewRefreshCallback();
} }
setPanelScrollTops();
if (isFirstTimeFilled) { if (isFirstTimeFilled) {
isFirstTimeFilled = false; isFirstTimeFilled = false;
return; return;
@ -1080,11 +1204,10 @@
// TODO: make option to disable. We don't need this in discourse // TODO: make option to disable. We don't need this in discourse
// setupEvents(panels.input, applyTimeout); // setupEvents(panels.input, applyTimeout);
setupScrollSync();
makePreviewHtml(); makePreviewHtml();
if (panels.preview) {
panels.preview.scrollTop = 0;
}
}; };
init(); init();