Range handling improvements. (#656)

closes https://github.com/TryGhost/Ghost/issues/8323
closes https://github.com/TryGhost/Ghost/issues/8191

Fixes some of the range issues that we're seeing across browsers also simplifies the positioning code for UI elements.
1. For the title the cursor is now placed in the correct place on key up and down.
2. For the body Safari now displays the `/` menu correctly.
This commit is contained in:
Ryan McCarvill 2017-04-20 22:01:08 +12:00 committed by Kevin Ansfield
parent 35bef04a7f
commit 53cbfcf9ca
5 changed files with 196 additions and 200 deletions

View File

@ -52,30 +52,10 @@ export default Component.extend({
} }
} }
if (event.keyCode === 13) { if (event.keyCode === 13) {
// enter // enter
// on enter we want to split the title, create a new paragraph in the mobile doc and insert it into the content. // on enter create a new paragraph at the top of the editor, this is because the first item may be a card.
let title = this.$('.gh-editor-title');
editor.run((postEditor) => { editor.run((postEditor) => {
let {anchorOffset, focusOffset} = window.getSelection(); let marker = editor.builder.createMarker('');
let text = title.text();
let startText = ''; // the text before the split
let endText = ''; // the text after the split
// if the selection is not collapsed then we have to delete the text that is selected.
if (anchorOffset !== focusOffset) {
// if the start of the selection is after the end then reverse the selection
if (anchorOffset > focusOffset) {
[anchorOffset, focusOffset] = [focusOffset, anchorOffset];
}
startText = text.substring(0, anchorOffset);
endText = text.substring(focusOffset);
} else {
startText = text.substring(0, anchorOffset);
endText = text.substring(anchorOffset);
}
title.html(startText);
let marker = editor.builder.createMarker(endText);
let newSection = editor.builder.createMarkupSection('p', [marker]); let newSection = editor.builder.createMarkupSection('p', [marker]);
postEditor.insertSectionBefore(editor.post.sections, newSection, editor.post.sections.head); postEditor.insertSectionBefore(editor.post.sections, newSection, editor.post.sections.head);
@ -95,6 +75,12 @@ export default Component.extend({
} }
let range = window.getSelection().getRangeAt(0); // get the actual range within the DOM. let range = window.getSelection().getRangeAt(0); // get the actual range within the DOM.
let cursorPositionOnScreen = range.getBoundingClientRect(); let cursorPositionOnScreen = range.getBoundingClientRect();
// in safari getBoundingClientRect on a range does not work if the range is collapsed.
if (cursorPositionOnScreen.bottom === 0) {
cursorPositionOnScreen = range.getClientRects()[0];
}
let offset = title.offset(); let offset = title.offset();
let bottomOfHeading = offset.top + title.height(); let bottomOfHeading = offset.top + title.height();
if (cursorPositionOnScreen.bottom > bottomOfHeading - 13) { if (cursorPositionOnScreen.bottom > bottomOfHeading - 13) {
@ -107,12 +93,9 @@ export default Component.extend({
window.getSelection().removeAllRanges(); window.getSelection().removeAllRanges();
$(editor.post.sections.head.renderNode.element).children('div').click(); $(editor.post.sections.head.renderNode.element).children('div').click();
}); });
return; return;
} }
let cursorPositionInEditor = editor.positionAtPoint(cursorPositionOnScreen.left, loc.top); let cursorPositionInEditor = editor.positionAtPoint(cursorPositionOnScreen.left, loc.top);
if (!cursorPositionInEditor || cursorPositionInEditor.isBlank) { if (!cursorPositionInEditor || cursorPositionInEditor.isBlank) {
editor.element.focus(); editor.element.focus();
} else { } else {
@ -163,7 +146,6 @@ export default Component.extend({
if (this.get('editorMenuIsOpen')) { if (this.get('editorMenuIsOpen')) {
return; return;
} }
let editor = this.get('editor'); let editor = this.get('editor');
if (event.keyCode === 38) { // up arrow if (event.keyCode === 38) { // up arrow
let selection = window.getSelection(); let selection = window.getSelection();
@ -172,11 +154,19 @@ export default Component.extend({
} }
let range = selection.getRangeAt(0); // get the actual range within the DOM. let range = selection.getRangeAt(0); // get the actual range within the DOM.
let cursorPositionOnScreen = range.getBoundingClientRect(); let cursorPositionOnScreen = range.getBoundingClientRect();
if (cursorPositionOnScreen.bottom === 0) {
cursorPositionOnScreen = range.getClientRects()[0];
}
let topOfEditor = editor.element.getBoundingClientRect().top; let topOfEditor = editor.element.getBoundingClientRect().top;
// if the current paragraph is empty then the position is 0 // if the current paragraph is empty then the position is 0
if (cursorPositionOnScreen.top === 0) { if (!cursorPositionOnScreen || cursorPositionOnScreen.top === 0) {
cursorPositionOnScreen = editor.activeSection.renderNode.element.getBoundingClientRect(); if (editor.activeSection.renderNode) {
cursorPositionOnScreen = editor.activeSection.renderNode.element.getBoundingClientRect();
} else {
this.setCursorAtOffset(0);
return false;
}
} }
if (cursorPositionOnScreen.top < topOfEditor + 33) { if (cursorPositionOnScreen.top < topOfEditor + 33) {
@ -197,7 +187,6 @@ export default Component.extend({
let range = document.createRange(); let range = document.createRange();
for (let i = len - 1; i > -1; i--) { for (let i = len - 1; i > -1; i--) {
// console.log(title);
range.setStart(title, i); range.setStart(title, i);
range.setEnd(title, i + 1); range.setEnd(title, i + 1);
let rect = range.getBoundingClientRect(); let rect = range.getBoundingClientRect();
@ -212,20 +201,24 @@ export default Component.extend({
return len; return len;
}, },
setCursorAtOffset() {
// position the users cursor in the title based on the offset.
// unfortunately creating a range and adding it to the selection doesn't work.
// In Chrome it ignores the new range and places the cursor at the start of the element.
// in Firefox it places the cursor at the correct place but refuses to accept keyboard input.
setCursorAtOffset(offset) {
let [title] = this.$('.gh-editor-title'); let [title] = this.$('.gh-editor-title');
title.focus(); title.focus();
// the following code sets the start point based on the offest provided. let selection = window.getSelection();
// it works in isolation of ghost-admin but in ghost-admin it doesn't work in Chrome
// and works in Firefox, but in firefox you can no longer edit the title once this has happened.
// It's either an issue with ghost-admin or mobiledoc and more investigation needs to be done.
// Probably after the beta release though.
// let range = document.createRange(); window.requestAnimationFrame(() => {
// let selection = window.getSelection(); run.join(() => {
// range.setStart(title.childNodes[0], offset); if (selection.modify) {
// range.collapse(true); for (let i = 0; i < offset; i++) {
// selection.removeAllRanges(); selection.modify('move', 'forward', 'character');
// selection.addRange(range); }
}
});
});
} }
}); });

View File

@ -7,7 +7,7 @@ import {MOBILEDOC_VERSION} from 'mobiledoc-kit/renderers/mobiledoc';
import createCardFactory from '../lib/card-factory'; import createCardFactory from '../lib/card-factory';
import defaultCommands from '../options/default-commands'; import defaultCommands from '../options/default-commands';
import editorCards from '../cards/index'; import editorCards from '../cards/index';
import {getCardFromDoc, checkIfClickEventShouldCloseCard} from '../lib/utils'; import {getCardFromDoc, checkIfClickEventShouldCloseCard, getPositionFromRange} from '../lib/utils';
import $ from 'jquery'; import $ from 'jquery';
// import { VALID_MARKUP_SECTION_TAGNAMES } from 'mobiledoc-kit/models/markup-section'; //the block elements supported by mobile-doc // import { VALID_MARKUP_SECTION_TAGNAMES } from 'mobiledoc-kit/models/markup-section'; //the block elements supported by mobile-doc
@ -154,27 +154,19 @@ export default Component.extend({
}, },
// makes sure the cursor is on screen except when selection is happening in which case the browser mostly ensures it. // makes sure the cursor is on screen except when selection is happening in which case the browser mostly ensures it.
// there is an issue with keyboard selection on some browsers though so the next step will be to record mouse and touch events. // there is an issue with keyboard selection on some browsers though so the next step may be to record mouse and touch events.
cursorMoved() { cursorMoved() {
let editor = this.get('editor'); let editor = this.get('editor');
if (editor.range.isCollapsed) { if (editor.range.isCollapsed) {
let scrollBuffer = 33; // the extra buffer to scroll. let scrollBuffer = 33; // the extra buffer to scroll.
let selection = window.getSelection();
if (!selection.rangeCount) { let position = getPositionFromRange(editor, $(this.get('containerSelector')));
if (!position) {
return; return;
} }
let range = selection.getRangeAt(0); // get the actual range within the DOM.
let position = range.getBoundingClientRect();
if (position.left === 0 && position.top === 0) {
// in safari if the range is collapsed you can't get it's location.
// this is a bug as it's against the spec.
if (editor.range.section) {
position = editor.range.head.section.renderNode.element.getBoundingClientRect();
} else {
return;
}
}
let windowHeight = window.innerHeight; let windowHeight = window.innerHeight;
if (position.bottom > windowHeight) { if (position.bottom > windowHeight) {

View File

@ -4,6 +4,7 @@ import run from 'ember-runloop';
import $ from 'jquery'; import $ from 'jquery';
import Tools from '../options/default-tools'; import Tools from '../options/default-tools';
import layout from '../templates/components/koenig-slash-menu'; import layout from '../templates/components/koenig-slash-menu';
import {getPositionFromRange} from '../lib/utils';
const ROW_LENGTH = 4; const ROW_LENGTH = 4;
@ -104,7 +105,7 @@ export default Component.extend({
}, },
actions: { actions: {
openMenu: function () { // eslint-disable-line openMenu: function () { // eslint-disable-line
let $editor = $(this.get('containerSelector')); let holder = $(this.get('containerSelector'));
let editor = this.get('editor'); let editor = this.get('editor');
let self = this; let self = this;
@ -198,28 +199,20 @@ export default Component.extend({
} }
}); });
let range = window.getSelection().getRangeAt(0); // get the actual range within the DOM. let position = getPositionFromRange(editor, holder);
let position = range.getBoundingClientRect();
let edOffset = $editor.offset();
if (position.left === 0 && position.top === 0) {
// in safari if the range is collapsed you can't get it's location.
// this is a bug as it's against the spec.
position = editor.range.head.section.renderNode.element.getBoundingClientRect();
}
run.schedule('afterRender', this, run.schedule('afterRender', this,
() => { () => {
let menu = this.$('.gh-cardmenu'); let menu = this.$('.gh-cardmenu');
let top = position.top + $editor.scrollTop() - edOffset.top + 20; let top = position.top + 20;
let left = position.left + (position.width / 2) + $editor.scrollLeft() - edOffset.left; let left = position.left + (position.width / 2);
// calculate if parts of the menu that are hidden by the overflow. // calculate if parts of the menu that are hidden by the overflow.
let hiddenByOverflowY = ($editor.innerHeight() + $editor.scrollTop()) - (menu.height() + top); let hiddenByOverflowY = (holder.innerHeight() + holder.scrollTop()) - (menu.height() + top);
// if the menu is off the bottom of the screen then place it above the cursor // if the menu is off the bottom of the screen then place it above the cursor
if (hiddenByOverflowY < 0) { if (hiddenByOverflowY < 0) {
menu.css('margin-top', -(menu.outerHeight() + 20)); menu.css('margin-top', -(menu.outerHeight() + 20));
} }
let hiddenByOverflowX = ($editor.innerWidth() + $editor.scrollLeft()) - (menu.width() + left); let hiddenByOverflowX = (holder.innerWidth() + holder.scrollLeft()) - (menu.width() + left);
// if the menu is off the bottom of the screen then place it above the cursor // if the menu is off the bottom of the screen then place it above the cursor
if (hiddenByOverflowX < 0) { if (hiddenByOverflowX < 0) {
menu.css('margin-left', -(menu.outerWidth() + 20)); menu.css('margin-left', -(menu.outerWidth() + 20));

View File

@ -5,6 +5,7 @@ import $ from 'jquery';
import layout from '../templates/components/koenig-toolbar'; import layout from '../templates/components/koenig-toolbar';
import cajaSanitizers from '../lib/caja-sanitizers'; import cajaSanitizers from '../lib/caja-sanitizers';
import Tools from '../options/default-tools'; import Tools from '../options/default-tools';
import {getPositionFromRange} from '../lib/utils';
export default Component.extend({ export default Component.extend({
layout, layout,
@ -25,9 +26,6 @@ export default Component.extend({
}), }),
toolbar: computed('tools.@each.selected', function () { toolbar: computed('tools.@each.selected', function () {
// TODO if a block section other than a primary section is selected then
// the returned list removes one of the primary sections to compensate,
// so that there are only ever four primary sections.
let visibleTools = []; let visibleTools = [];
this.tools.forEach((tool) => { this.tools.forEach((tool) => {
@ -39,9 +37,6 @@ export default Component.extend({
}), }),
toolbarBlocks: computed('tools.@each.selected', function () { toolbarBlocks: computed('tools.@each.selected', function () {
// TODO if a block section other than a primary section is selected then
// the returned list removes one of the primary sections to compensate,
// so that there are only ever four primary sections.
let visibleTools = []; let visibleTools = [];
this.tools.forEach((tool) => { this.tools.forEach((tool) => {
@ -64,15 +59,15 @@ export default Component.extend({
} }
let toolbar = this.$(); let toolbar = this.$();
let {editor} = this; let {editor} = this;
let $editor = $(this.get('containerSelector')); // TODO - this element is part of ghost-admin, we need to separate them more. let holder = $(this.get('containerSelector'));
let isMousedown = false; let isMousedown = false;
$editor.mousedown(() => isMousedown = true); holder.mousedown(() => isMousedown = true);
$editor.mouseup(() => { holder.mouseup(() => {
isMousedown = false; isMousedown = false;
updateToolbarToRange(this, toolbar, $editor, isMousedown); this.updateToolbarToRange(toolbar, holder, isMousedown);
}); });
editor.cursorDidChange(() => updateToolbarToRange(this, toolbar, $editor, isMousedown)); editor.cursorDidChange(() => this.updateToolbarToRange(toolbar, holder, isMousedown));
this.set('hasRendered', true); this.set('hasRendered', true);
}, },
@ -80,6 +75,95 @@ export default Component.extend({
this.editor.destroy(); this.editor.destroy();
}, },
// update the location of the toolbar and display it if the range is visible.
updateToolbarToRange(toolbar, holder, isMouseDown) {
// if there is no cursor:
let editor = this.get('editor');
if (!editor.range || editor.range.head.isBlank || isMouseDown) {
if (!this.get('isLink')) {
this.set('isVisible', false);
}
return;
}
// set the active markups and sections
let sectionTagName = editor.activeSection.tagName === 'li' ? editor.activeSection.parent.tagName : editor.activeSection.tagName;
this.set('activeTags', editor.activeMarkups.concat([{tagName: sectionTagName}]));
// if we have a selection, then the toolbar appears just above said selection:
// unless it's a selection around a single card (firefox bug)
if (!editor.range.isCollapsed
&& !(editor.range.head.section.isCardSection && editor.range.head.section === editor.range.tail.section)) {
let position = getPositionFromRange(editor, holder);
this.set('isVisible', true);
run.schedule('afterRender', this,
() => {
// if we're in touch mode we just use CSS to display the toolbar.
if (this.get('isTouch')) {
return;
}
let width = toolbar.width();
let height = toolbar.height();
let top = position.top - toolbar.height() - 20;
let left = position.left + (position.width / 2) - (width / 2);
let right = left + width;
let edWidth = holder[0].scrollWidth;
if (left < 0) {
if (Math.round(left / (width / 4)) === -1) {
this.setTickPosition('tickFullLeft');
} else {
this.setTickPosition('tickHalfLeft');
}
left = 0;
} else if (right > edWidth) {
if (Math.round((edWidth - right) / (width / 4)) === -1) {
this.setTickPosition('tickFullRight');
} else {
this.setTickPosition('tickHalfRight');
}
left = left + (edWidth - right);
} else {
this.setTickPosition(null);
}
if (!this.get('isTouch') && top - holder.scrollTop() < 0) {
top = top + height + 60;
this.set('tickAbove', true);
} else {
this.set('tickAbove', false);
}
toolbar.css('top', top);
toolbar.css('left', left);
}
);
this.send('closeLink');
this.tools.forEach((tool) => {
if (tool.hasOwnProperty('checkElements')) {
// if its a list we want to know what type
let sectionTagName = editor.activeSection._tagName === 'li' ? editor.activeSection.parent._tagName : editor.activeSection._tagName;
tool.checkElements(editor.activeMarkups.concat([{tagName: sectionTagName}]));
}
});
} else {
if (this.isVisible) {
this.set('isVisible', false);
this.send('closeLink');
}
}
},
// set the location of the 'tick' arrow that appears at the bottom of the toolbar and points out the selection.
setTickPosition(tickPosition) {
let positions = ['tickFullLeft', 'tickHalfLeft', 'tickFullRight', 'tickHalfRight'];
positions.forEach((position) => {
this.set(position, position === tickPosition);
});
},
actions: { actions: {
linkKeyDown(event) { linkKeyDown(event) {
// if escape close link // if escape close link
@ -132,106 +216,3 @@ export default Component.extend({
} }
}); });
// update the location of the toolbar and display it if the range is visible.
function updateToolbarToRange(self, $holder, $editor, isMouseDown) {
// if there is no cursor:
let {editor} = self;
if (!editor.range || editor.range.head.isBlank || isMouseDown) {
if (!self.get('isLink')) {
self.set('isVisible', false);
}
return;
}
// set the active markups and sections
let sectionTagName = editor.activeSection.tagName === 'li' ? editor.activeSection.parent.tagName : editor.activeSection.tagName;
self.set('activeTags', editor.activeMarkups.concat([{tagName: sectionTagName}]));
self.propertyWillChange('toolbar');
self.propertyWillChange('toolbarBlocks');
// if we have a selection, then the toolbar appears just above said selection:
// unless it's a selection around a single card (firefox bug)
if (!editor.range.isCollapsed
&& !(editor.range.head.section.isCardSection && editor.range.head.section === editor.range.tail.section)) {
let range = window.getSelection().getRangeAt(0); // get the actual range within the DOM.
let position = range.getBoundingClientRect();
let edOffset = $editor.offset();
self.set('isVisible', true);
run.schedule('afterRender', this,
() => {
// if we're in touch mode we just use CSS to display the toolbar.
if (self.get('isTouch')) {
return;
}
let width = $holder.width();
let height = $holder.height();
let top = position.top + $editor.scrollTop() - $holder.height() - 20;
let left = position.left + (position.width / 2) + $editor.scrollLeft() - edOffset.left - (width / 2);
let right = left + width;
let edWidth = $editor[0].scrollWidth;
if (left < 0) {
if (Math.round(left / (width / 4)) === -1) {
self.set('tickFullLeft', true);
self.set('tickHalfLeft', false);
self.set('tickFullRight', false);
self.set('tickHalfRight', false);
} else {
self.set('tickFullLeft', false);
self.set('tickHalfLeft', true);
self.set('tickFullRight', false);
self.set('tickHalfRight', false);
}
left = 0;
} else if (right > edWidth) {
if (Math.round((edWidth - right) / (width / 4)) === -1) {
self.set('tickFullLeft', false);
self.set('tickHalfLeft', false);
self.set('tickFullRight', true);
self.set('tickHalfRight', false);
} else {
self.set('tickFullLeft', false);
self.set('tickHalfLeft', false);
self.set('tickFullRight', false);
self.set('tickHalfRight', true);
}
left = left + (edWidth - right);
} else {
self.set('tickFullLeft', false);
self.set('tickHalfLeft', false);
self.set('tickFullRight', false);
self.set('tickHalfRight', false);
}
if (!self.get('isTouch') && top - $editor.scrollTop() < 0) {
top = top + height + 60;
self.set('tickAbove', true);
} else {
self.set('tickAbove', false);
}
$holder.css('top', top);
$holder.css('left', left);
}
);
self.send('closeLink');
self.tools.forEach((tool) => {
if (tool.hasOwnProperty('checkElements')) {
// if its a list we want to know what type
let sectionTagName = editor.activeSection._tagName === 'li' ? editor.activeSection.parent._tagName : editor.activeSection._tagName;
tool.checkElements(editor.activeMarkups.concat([{tagName: sectionTagName}]));
}
});
} else {
if (self.isVisible) {
self.set('isVisible', false);
self.send('closeLink');
}
}
self.propertyDidChange('toolbar');
self.propertyDidChange('toolbarBlocks');
}

View File

@ -38,22 +38,59 @@ export function checkIfClickEventShouldCloseCard(target, cardHolder) {
// in Chrome, Firefox, and Edge range.getBoundingClientRect() works // in Chrome, Firefox, and Edge range.getBoundingClientRect() works
// in Safari if the range is collapsed you get nothing so we expand the range by 1 // in Safari if the range is collapsed you get nothing so we expand the range by 1
// if that doesn't work then we fallback got the paragraph. // if that doesn't work then we fallback got the paragraph.
// export function getPositionFromRange(editor, range = window.getSelection().getRangeAt(0)) { export function getPositionFromRange(editor, holder, range) {
// let {top, left} = editor.element.getBoundingClientRect(); if (!editor.range || !editor.range.head || !editor.range.head.section) {
// let position = range.getBoundingClientRect(); return;
}
let position;
let offset = holder.offset();
let selection = window.getSelection();
// if (position.left === 0 && position.top === 0) { if (!range && selection.rangeCount) {
// // in safari if the range is collapsed you can't get it's location. range = selection.getRangeAt(0);
// // this is a bug as it's against the spec. }
// position = getCursorPositionSafari(range);
// // position = editor.range.head.section.renderNode.element.getBoundingClientRect();
// }
// }
// function getCursorPositionSafari(range) { if (range) {
// if(offset < container.length) { if (range.getBoundingClientRect) {
let rect = range.getBoundingClientRect();
position = {left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom};
}
// } else { // if getBoundingClientRect doesn't work then create it from the client rects
if ((!position || (position.left === 0 && position.top === 0)) && range.getClientRects) {
// } let rects = range.getClientRects();
// }
for (let i = 0; i < rects.length; i++) {
let rect = rects[i];
if (position.left === 0 || position.left > rect.left) {
position.left = rect.left;
}
if (position.top === 0 || position.top > rect.top) {
position.top = rect.top;
}
if (position.right < rect.right) {
position.right = rect.right;
}
if (position.bottom < rect.bottom) {
position.bototm = rect.bottom;
}
}
}
}
// if we can't get the position from either getBoundingClientRect or getClientRects then get it based on the paragraph.
if (!position || (position && position.left === 0 && position.top === 0)) {
let rect = editor.range.head.section.renderNode.element.getBoundingClientRect();
position = {left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom};
}
return {
left: position.left + holder.scrollLeft() - offset.left,
right: position.right + holder.scrollLeft() - offset.left,
top: position.top + holder.scrollTop() - offset.top,
bottom: position.bottom + holder.scrollTop() - offset.top,
width: position.right - position.left,
height: position.bottom - position.top
};
}