From 297c25ca1fba1b76b3981ba0b4f83251175ec2e7 Mon Sep 17 00:00:00 2001 From: Wojciech Zawistowski Date: Thu, 27 Mar 2014 10:18:48 -0400 Subject: [PATCH] Synced editor scrolling PoC. --- .../javascripts/discourse/models/composer.js | 8 +- .../templates/composer.js.handlebars | 1 + .../discourse/views/composer/composer_view.js | 8 - app/assets/stylesheets/desktop/compose.scss | 19 +- app/assets/stylesheets/mobile/compose.scss | 19 +- vendor/assets/javascripts/Markdown.Editor.js | 187 +++++++++++++++--- 6 files changed, 195 insertions(+), 47 deletions(-) diff --git a/app/assets/javascripts/discourse/models/composer.js b/app/assets/javascripts/discourse/models/composer.js index b020cf50c..b4af496e4 100644 --- a/app/assets/javascripts/discourse/models/composer.js +++ b/app/assets/javascripts/discourse/models/composer.js @@ -411,7 +411,7 @@ Discourse.Composer = Discourse.Model.extend({ raw: this.get('reply'), editReason: opts.editReason, imageSizes: opts.imageSizes, - cooked: $('#wmd-preview').html() + cooked: this.getCookedHtml() }); this.set('composeState', CLOSED); @@ -448,7 +448,7 @@ Discourse.Composer = Discourse.Model.extend({ topic_id: this.get('topic.id'), reply_to_post_number: post ? post.get('post_number') : null, imageSizes: opts.imageSizes, - cooked: $('#wmd-preview').html(), + cooked: this.getCookedHtml(), reply_count: 0, display_username: currentUser.get('name'), username: currentUser.get('username'), @@ -534,6 +534,10 @@ Discourse.Composer = Discourse.Model.extend({ }); }, + getCookedHtml: function() { + return $('#wmd-preview').html().replace(/<\/span>/g, ''); + }, + saveDraft: function() { // Do not save when drafts are disabled if (this.get('disableDrafts')) return; diff --git a/app/assets/javascripts/discourse/templates/composer.js.handlebars b/app/assets/javascripts/discourse/templates/composer.js.handlebars index 8a77881d3..fffd0fed8 100644 --- a/app/assets/javascripts/discourse/templates/composer.js.handlebars +++ b/app/assets/javascripts/discourse/templates/composer.js.handlebars @@ -56,6 +56,7 @@
+
{{view Discourse.NotifyingTextArea parentBinding="view" tabindex="3" valueBinding="model.reply" id="wmd-input" placeholderKey="composer.reply_placeholder"}} {{popupInputTip validation=view.replyValidation shownAt=view.showReplyTip}}
diff --git a/app/assets/javascripts/discourse/views/composer/composer_view.js b/app/assets/javascripts/discourse/views/composer/composer_view.js index dba579137..1305bc2ba 100644 --- a/app/assets/javascripts/discourse/views/composer/composer_view.js +++ b/app/assets/javascripts/discourse/views/composer/composer_view.js @@ -52,14 +52,6 @@ Discourse.ComposerView = Discourse.View.extend(Ember.Evented, { refreshPreview: Discourse.debounce(function() { if (this.editor) { 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), diff --git a/app/assets/stylesheets/desktop/compose.scss b/app/assets/stylesheets/desktop/compose.scss index c5a13733a..a560fbad6 100644 --- a/app/assets/stylesheets/desktop/compose.scss +++ b/app/assets/stylesheets/desktop/compose.scss @@ -474,7 +474,7 @@ div.ac-wrap { margin-top: 0 !important; } - #wmd-input, #wmd-preview { + #wmd-input, #wmd-preview-scroller, #wmd-preview { @include box-sizing(border-box); width: 100%; height: 100%; @@ -488,8 +488,11 @@ div.ac-wrap { h1, h2, h3, h4, h5, h6 { margin: 30px 0 10px; } + p { + margin-top: 19px; + } } - #wmd-input { + #wmd-input, #wmd-preview-scroller { position: absolute; left: 0; top: 0; @@ -502,6 +505,18 @@ div.ac-wrap { @include border-radius-all(0); 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 { position: relative; @include box-sizing(border-box); diff --git a/app/assets/stylesheets/mobile/compose.scss b/app/assets/stylesheets/mobile/compose.scss index b004b1dab..96f6d8125 100644 --- a/app/assets/stylesheets/mobile/compose.scss +++ b/app/assets/stylesheets/mobile/compose.scss @@ -344,7 +344,6 @@ div.ac-wrap { font-size: 16px; } } -#reply-control .wmd-controls #wmd-input {font-size: 16px;} #reply-control.edit-title.private-message { .wmd-controls { @@ -387,7 +386,7 @@ div.ac-wrap { margin-top: 0 !important; } - #wmd-input, #wmd-preview { + #wmd-input, #wmd-preview-scroller, #wmd-preview { @include box-sizing(border-box); width: 100%; height: 100%; @@ -401,8 +400,11 @@ div.ac-wrap { h1, h2, h3, h4, h5, h6 { margin: 30px 0 10px; } + p { + margin-top: 19px; + } } - #wmd-input { + #wmd-input, #wmd-preview-scroller { position: absolute; left: 0; top: 0; @@ -413,6 +415,17 @@ div.ac-wrap { border-top: 36px solid transparent; @include border-radius-all(0); 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 { position: relative; diff --git a/vendor/assets/javascripts/Markdown.Editor.js b/vendor/assets/javascripts/Markdown.Editor.js index af4aa9097..398dbe410 100644 --- a/vendor/assets/javascripts/Markdown.Editor.js +++ b/vendor/assets/javascripts/Markdown.Editor.js @@ -330,6 +330,7 @@ function PanelCollection(postfix) { this.buttonBar = doc.getElementById("wmd-button-bar" + postfix); this.preview = doc.getElementById("wmd-preview" + postfix); + this.previewScroller = doc.getElementById("wmd-preview-scroller" + postfix); this.input = doc.getElementById("wmd-input" + postfix); }; @@ -861,9 +862,116 @@ var maxDelay = 3000; 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 var setupEvents = function (inputElem, listener) { - + util.addEvent(inputElem, "input", listener); inputElem.onpaste = listener; inputElem.ondrop = listener; @@ -909,14 +1017,33 @@ 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(/

~~marker~~<\/p>/g, '') + .replace(/~~marker~~/g, ''); + + previewScrollerText = text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/(\n|\r|\r\n)/g, '
') + .replace('~~caret~~', '') + .replace(/~~marker~~

/g, ''); // Calculate the processing time of the HTML creation. // It's used as the delay time in the event listener. var currTime = new Date().getTime(); elapsedTime = currTime - prevTime; - pushPreviewHtml(text); + Ember.run(function() { + pushPreviewHtml(previewText, previewScrollerText); + cacheMarkerPositions(); + cacheCaretMarkerPosition(); + syncScroll(true); + }); }; // makePreviewHtml = window.probes.measure(makePreviewHtml, { @@ -990,12 +1117,6 @@ 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) { if (requiresRefresh) { oldInputText = ""; @@ -1015,49 +1136,52 @@ // 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 // value, and reattach. Yes, that *is* ridiculous. - var ieSafePreviewSet = function (text) { - var preview = panels.preview; - var parent = preview.parentNode; - var sibling = preview.nextSibling; - parent.removeChild(preview); - preview.innerHTML = text; - if (!sibling) - parent.appendChild(preview); - else - parent.insertBefore(preview, sibling); + var ieSafePreviewSet = function (previewText, previewScrollerText) { + var ieSafeSet = function(panel, text) { + var parent = panel.parentNode; + var sibling = panel.nextSibling; + parent.removeChild(panel); + panel.innerHTML = text; + if (!sibling) + parent.appendChild(panel); + else + parent.insertBefore(panel, sibling); + }; + + ieSafeSet(panels.preview, previewText); + ieSafeSet(panels.previewScroller, previewScrollerText); } - var nonSuckyBrowserPreviewSet = function (text) { - panels.preview.innerHTML = text; + var nonSuckyBrowserPreviewSet = function (previewText, previewScrollerText) { + panels.preview.innerHTML = previewText; + panels.previewScroller.innerHTML = previewScrollerText; } var previewSetter; - var previewSet = function (text) { + var previewSet = function (previewText, previewScrollerText) { if (previewSetter) - return previewSetter(text); + return previewSetter(previewText, previewScrollerText); try { - nonSuckyBrowserPreviewSet(text); + nonSuckyBrowserPreviewSet(previewText, previewScrollerText); previewSetter = nonSuckyBrowserPreviewSet; } catch (e) { previewSetter = ieSafePreviewSet; - previewSetter(text); + previewSetter(previewText, previewScrollerText); } }; - var pushPreviewHtml = function (text) { + var pushPreviewHtml = function (previewText, previewScrollerText) { var emptyTop = position.getTop(panels.input) - getDocScrollTop(); if (panels.preview) { - previewSet(text); + previewSet(previewText, previewScrollerText); previewRefreshCallback(); } - setPanelScrollTops(); - if (isFirstTimeFilled) { isFirstTimeFilled = false; return; @@ -1080,11 +1204,10 @@ // TODO: make option to disable. We don't need this in discourse // setupEvents(panels.input, applyTimeout); + setupScrollSync(); + makePreviewHtml(); - if (panels.preview) { - panels.preview.scrollTop = 0; - } }; init();