Ghost/ghost/admin/lib/koenig-editor/addon/components/koenig-link-input.js
Kevin Ansfield f021badb9f 🐛 Fixed unreachable toolbar when editing wrapped links (#1511)
closes https://github.com/TryGhost/Ghost/issues/9792

- use `getClientRects()` to get separate rectangles for each line of a link and use the mouse position to find the closest one so that the toolbar can be positioned relative to that link section on that line rather than always in the middle of the editor canvas
- pass the rectangle used for positioning the link toolbar through to the link input component so that there is no jumping of position when clicking the edit button
2020-03-07 18:22:56 +00:00

278 lines
9.6 KiB
JavaScript

import Component from '@ember/component';
import layout from '../templates/components/koenig-link-input';
import relativeToAbsolute from '../lib/relative-to-absolute';
import {TOOLBAR_MARGIN} from './koenig-toolbar';
import {computed} from '@ember/object';
import {getLinkMarkupFromRange} from '../utils/markup-utils';
import {htmlSafe} from '@ember/string';
import {run} from '@ember/runloop';
import {inject as service} from '@ember/service';
// pixels that should be added to the `left` property of the tick adjustment styles
// TODO: handle via CSS?
const TICK_ADJUSTMENT = 8;
// TODO: move to a util
function getScrollParent(node) {
const isElement = node instanceof HTMLElement;
const overflowY = isElement && window.getComputedStyle(node).overflowY;
const isScrollable = overflowY !== 'visible' && overflowY !== 'hidden';
if (!node) {
return null;
} else if (isScrollable && node.scrollHeight >= node.clientHeight) {
return node;
}
return getScrollParent(node.parentNode) || document.body;
}
export default Component.extend({
config: service(),
layout,
attributeBindings: ['style'],
classNames: ['kg-input-bar', 'absolute', 'z-999'],
// public attrs
editor: null,
linkRange: null,
linkRect: null,
selectedRange: null,
// internal properties
top: null,
left: null,
right: null,
href: '',
// private properties
_selectedRange: null,
_windowRange: null,
_onMousedownHandler: null,
_onMouseupHandler: null,
// closure actions
cancel() {},
/* computed properties -------------------------------------------------- */
style: computed('top', 'left', 'right', function () {
let position = this.getProperties('top', 'left', 'right');
let styles = Object.keys(position).map((style) => {
if (position[style] !== null) {
return `${style}: ${position[style]}px`;
}
});
return htmlSafe(styles.compact().join('; '));
}),
/* lifecycle hooks ------------------------------------------------------ */
init() {
this._super(...arguments);
// record the range now because the property is bound and will update
// as we make changes whilst calculating the link position
this._selectedRange = this.selectedRange;
this._linkRange = this.linkRange;
// grab a window range so that we can use getBoundingClientRect. Using
// document.createRange is more efficient than doing editor.setRange
// because it doesn't trigger all of the selection changing side-effects
// TODO: extract MobiledocRange->NativeRange into a util
let editor = this.editor;
let cursor = editor.cursor;
let {head, tail} = this._linkRange;
let {node: headNode, offset: headOffset} = cursor._findNodeForPosition(head);
let {node: tailNode, offset: tailOffset} = cursor._findNodeForPosition(tail);
let range = document.createRange();
range.setStart(headNode, headOffset);
range.setEnd(tailNode, tailOffset);
this._windowRange = range;
// wait until rendered to position so that we have access to this.element
run.schedule('afterRender', this, function () {
this._positionToolbar();
this._focusInput();
});
// grab an existing href value if there is one
this._getHrefFromMarkup();
// watch the window for mousedown events so that we can close the menu
// when we detect a click outside
this._onMousedownHandler = run.bind(this, this._handleMousedown);
window.addEventListener('mousedown', this._onMousedownHandler);
// watch for keydown events so that we can close the menu on Escape
this._onKeydownHandler = run.bind(this, this._handleKeydown);
window.addEventListener('keydown', this._onKeydownHandler);
},
willDestroyElement() {
this._super(...arguments);
this._removeStyleElement();
window.removeEventListener('mousedown', this._onMousedownHandler);
window.removeEventListener('keydown', this._onKeydownHandler);
},
actions: {
inputKeydown(event) {
if (event.key === 'Enter') {
// prevent Enter from triggering in the editor and removing text
event.preventDefault();
let href = relativeToAbsolute(this.href, this.config.get('blogUrl'));
this.set('href', href);
// create a single editor runloop here so that we don't get
// separate remove and replace ops pushed onto the undo stack
this.editor.run((postEditor) => {
if (href) {
this._replaceLink(href, postEditor);
} else {
this._removeLinks(postEditor);
}
});
this._cancelAndReselect();
}
},
clear() {
this.set('href', '');
this._focusInput();
}
},
// if we have a single link or a slice of a single link selected, grab the
// href and adjust our linkRange to encompass the whole link
_getHrefFromMarkup() {
let linkMarkup = getLinkMarkupFromRange(this._linkRange);
if (linkMarkup) {
this.set('href', linkMarkup.attributes.href);
this._linkRange = this._linkRange.expandByMarker(marker => !!marker.markups.includes(linkMarkup));
}
},
_replaceLink(href, postEditor) {
this._removeLinks(postEditor);
let linkMarkup = postEditor.builder.createMarkup('a', {href});
postEditor.toggleMarkup(linkMarkup, this._linkRange);
},
// loop over all markers that are touched by linkRange, removing any 'a'
// markups on them to clear all links
_removeLinks(postEditor) {
let {headMarker, tailMarker} = this.linkRange;
let curMarker = headMarker;
while (curMarker && curMarker !== tailMarker.next) {
curMarker.markups.filterBy('tagName', 'a').forEach((markup) => {
curMarker.removeMarkup(markup);
postEditor._markDirty(curMarker);
});
curMarker = curMarker.next;
}
},
_cancelAndReselect() {
this.cancel();
if (this._selectedRange) {
this.editor.selectRange(this._selectedRange);
}
},
_focusInput() {
let scrollParent = getScrollParent(this.element);
let scrollTop = scrollParent.scrollTop;
this.element.querySelector('input').focus();
// reset the scroll position to avoid jumps
// TODO: why does the input focus cause a scroll to the bottom of the doc?
scrollParent.scrollTop = scrollTop;
},
// TODO: largely shared with {{koenig-toolbar}} code - extract to a shared util?
_positionToolbar() {
let containerRect = this.element.offsetParent.getBoundingClientRect();
let rangeRect = this.linkRect || this._windowRange.getBoundingClientRect();
let {width, height} = this.element.getBoundingClientRect();
let newPosition = {};
// rangeRect is relative to the viewport so we need to subtract the
// container measurements to get a position relative to the container
newPosition = {
top: rangeRect.top - containerRect.top - height - TOOLBAR_MARGIN,
left: rangeRect.left - containerRect.left + rangeRect.width / 2 - width / 2,
right: null
};
let tickPosition = 50;
// don't overflow left boundary
if (newPosition.left < 0) {
newPosition.left = 0;
// calculate the tick percentage position
let absTickPosition = rangeRect.left - containerRect.left + rangeRect.width / 2;
tickPosition = absTickPosition / width * 100;
if (tickPosition < 5) {
tickPosition = 5;
}
}
// same for right boundary
if (newPosition.left + width > containerRect.width) {
newPosition.left = null;
newPosition.right = 0;
// calculate the tick percentage position
let absTickPosition = rangeRect.right - containerRect.right - rangeRect.width / 2;
tickPosition = 100 + absTickPosition / width * 100;
if (tickPosition > 95) {
tickPosition = 95;
}
}
// the tick is a pseudo-element so we the only way we can affect it's
// style is by adding a style element to the head
this._removeStyleElement(); // reset to base styles
if (tickPosition !== 50) {
this._addStyleElement(`left: calc(${tickPosition}% - ${TICK_ADJUSTMENT}px)`);
}
// update the toolbar position
this.setProperties(newPosition);
},
_addStyleElement(styles) {
let styleElement = document.createElement('style');
styleElement.id = `${this.elementId}-style`;
styleElement.innerHTML = `#${this.elementId}:before, #${this.elementId}:after { ${styles} }`;
document.head.appendChild(styleElement);
},
_removeStyleElement() {
let styleElement = document.querySelector(`#${this.elementId}-style`);
if (styleElement) {
styleElement.remove();
}
},
_handleMousedown(event) {
if (!event.target.closest(`#${this.elementId}`)) {
// no need to re-select for mouse clicks
this.cancel();
}
},
_handleKeydown(event) {
if (event.key === 'Escape') {
this._cancelAndReselect();
}
}
});