Koenig - Keep cursor on screen when typing or moving via keyboard (#1012)

refs https://github.com/TryGhost/Ghost/issues/9505
- when cursor changes through the normal `cursorDidChange` or through certain programmatic changes we trigger a check to see if the cursor is out of the viewport and scroll it into view if necessary
- disable our scroll-into-view routine if the mouse button or shift key is down so that we don't interfere with default browser behaviour which works well in that situation
- for scroll-into-view at the bottom there are two slightly different methods
    - if the cursor is near the bottom of the document we scroll so that the bottom padding of the editor is visible
    - if the cursor is mid-document then we scroll just enough to bring the cursor into the viewport
This commit is contained in:
Kevin Ansfield 2018-05-24 13:30:50 +01:00 committed by GitHub
parent d1ad53c149
commit e68ec9d1df
4 changed files with 107 additions and 5 deletions

View File

@ -21,5 +21,8 @@
onChange=(action "onBodyChange")
didCreateEditor=(action "onEditorCreated")
cursorDidExitAtTop=(action "focusTitle")
scrollContainerSelector=scrollContainerSelector
scrollOffsetTopSelector=scrollOffsetTopSelector
scrollOffsetBottomSelector=scrollOffsetBottomSelector
}}
</div>

View File

@ -71,6 +71,9 @@
bodyAutofocus=shouldFocusEditor
onBodyChange=(action "updateScratch")
headerOffset=editor.headerHeight
scrollContainerSelector=".gh-koenig-editor"
scrollOffsetTopSelector=".gh-editor-header-small"
scrollOffsetBottomSelector=".gh-mobile-nav-bar"
}}
{{else}}
@ -205,4 +208,4 @@
{{/liquid-wormhole}}
{{/if}}
{{outlet}}
{{outlet}}

View File

@ -117,7 +117,6 @@ export default Component.extend({
autofocus: false,
spellcheck: true,
options: null,
scrollContainer: '',
headerOffset: 0,
// internal properties
@ -146,8 +145,7 @@ export default Component.extend({
/* computed properties -------------------------------------------------- */
// merge in named options with the `options` property data-bag
// TODO: what is the `options` property data-bag and when/where does it get set?
// merge in named options with any passed in `options` property data-bag
editorOptions: computed(function () {
let options = this.options || {};
let atoms = this.atoms || [];
@ -190,6 +188,16 @@ export default Component.extend({
ctrl: false
};
// track mousedown/mouseup on the window rather than the ember component
// so that we're sure to get the events even when they start outside of
// this component or end outside the window.
// Mouse events are used to track when a mousebutton is down so that we
// can disable automatic cursor-in-viewport scrolling
this._onMousedownHandler = run.bind(this, this.handleMousedown);
window.addEventListener('mousedown', this._onMousedownHandler);
this._onMouseupHandler = run.bind(this, this.handleMouseup);
window.addEventListener('mouseup', this._onMouseupHandler);
this._startedRunLoop = false;
},
@ -346,6 +354,10 @@ export default Component.extend({
this._pasteHandler = run.bind(this, this.handlePaste);
editorElement.addEventListener('paste', this._pasteHandler);
if (this.scrollContainerSelector) {
this._scrollContainer = document.querySelector(this.scrollContainerSelector);
}
},
// our ember component has rendered, now we need to render the mobiledoc
@ -538,6 +550,7 @@ export default Component.extend({
if (this._skipCursorChange) {
this._skipCursorChange = false;
this.set('selectedRange', editor.range);
this._scrollCursorIntoView();
return;
}
@ -584,6 +597,7 @@ export default Component.extend({
// pass the selected range through to the toolbar + menu components
this.set('selectedRange', editor.range);
this._scrollCursorIntoView();
},
// fired when the active section(s) or markup(s) at the current cursor
@ -711,6 +725,19 @@ export default Component.extend({
}
},
handleMousedown(event) {
// we only care about the left mouse button
if (event.which === 1) {
this._isMouseDown = true;
}
},
handleMouseup(event) {
if (event.which === 1) {
this._isMouseDown = false;
}
},
/* Ember event handlers ------------------------------------------------- */
// disable dragging
@ -879,5 +906,73 @@ export default Component.extend({
if (this.element && config.environment === 'test') {
this.element[TESTING_EXPANDO_PROPERTY] = editor;
}
},
_scrollCursorIntoView() {
// disable auto-scroll if the mouse or shift key is being used to create
// a selection - the browser handles scrolling well in this case
if (!this._scrollContainer || this._isMouseDown || this._modifierKeys.shift) {
return;
}
let {range} = this.editor;
let selection = window.getSelection();
let windowRange = selection && selection.getRangeAt(0);
let element = range.head && range.head.section && range.head.section.renderNode && range.head.section.renderNode.element;
if (windowRange) {
// cursorTop is relative to the window rather than document or scroll container
let {top: cursorTop, height: cursorHeight} = windowRange.getBoundingClientRect();
let viewportHeight = window.innerHeight;
let offsetTop = 0;
let offsetBottom = 0;
let scrollTop = this._scrollContainer.scrollTop;
if (this.scrollOffsetTopSelector) {
let topElement = document.querySelector(this.scrollOffsetTopSelector);
offsetTop = topElement ? topElement.offsetHeight : 0;
}
if (this.scrollOffsetBottomSelector) {
let bottomElement = document.querySelector(this.scrollOffsetBottomSelector);
offsetBottom = bottomElement ? bottomElement.offsetHeight : 0;
}
// for empty paragraphs the window selection range will be 0,0,0,0
// so grab the element's bounding rect instead
if (cursorTop === 0 && cursorHeight === 0) {
if (!element) {
return;
}
({top: cursorTop, height: cursorHeight} = element.getBoundingClientRect());
}
// keep cursor in view at the top
if (cursorTop < 0 + offsetTop) {
this._scrollContainer.scrollTop = scrollTop - offsetTop + cursorTop - 20;
return;
}
let cursorBottom = cursorTop + cursorHeight;
let paddingBottom = 0;
let distanceFromViewportBottom = cursorBottom - viewportHeight;
let atBottom = false;
// if we're at the bottom of the doc we should keep the bottom
// padding in view, otherwise just scroll to keep the cursor in view
if (this._scrollContainer.scrollTop + this._scrollContainer.offsetHeight + 200 >= this._scrollContainer.scrollHeight) {
atBottom = true;
paddingBottom = parseFloat(getComputedStyle(this.element.parentNode).getPropertyValue('padding-bottom'));
}
if (cursorBottom > viewportHeight - offsetBottom - paddingBottom) {
if (atBottom) {
this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight;
} else {
this._scrollContainer.scrollTop = scrollTop + offsetBottom + distanceFromViewportBottom + 20;
}
}
}
}
});

View File

@ -10,7 +10,7 @@ import {
export const DEFAULT_KEY_COMMANDS = [{
str: 'ENTER',
run(editor) {
run(editor, koenig) {
let {isCollapsed, head: {offset, section}} = editor.range;
// if cursor is at beginning of a heading, insert a blank paragraph above
@ -20,6 +20,7 @@ export const DEFAULT_KEY_COMMANDS = [{
let collection = section.parent.sections;
postEditor.insertSectionBefore(collection, newPara, section);
});
koenig._scrollCursorIntoView();
return;
}