mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-22 10:21:36 +03:00
b402b1643e
closes sentry Admin-423 - there may be times when the mousemove event handler fires when the document is not in a ready state resulting in an attempt to get a document position that doesn't exist - should fix `Could not find parent section from element node` errors
260 lines
8.6 KiB
JavaScript
260 lines
8.6 KiB
JavaScript
import Component from '@ember/component';
|
|
import classic from 'ember-classic-decorator';
|
|
import relativeToAbsolute from '../lib/relative-to-absolute';
|
|
import {action, computed} from '@ember/object';
|
|
import {attributeBindings, classNames} from '@ember-decorators/component';
|
|
import {getEventTargetMatchingTag} from 'mobiledoc-kit/utils/element-utils';
|
|
import {htmlSafe} from '@ember/template';
|
|
import {inject} from 'ghost-admin/decorators/inject';
|
|
import {run} from '@ember/runloop';
|
|
|
|
// gap between link and toolbar bottom
|
|
const TOOLBAR_MARGIN = 8;
|
|
// extra padding to reduce the likelyhood of unexpected hiding
|
|
// TODO: improve behaviour with a mouseout timeout or creating a bounding box
|
|
// and watching mousemove
|
|
const TOOLBAR_PADDING = 12;
|
|
|
|
// ms to wait before showing the tooltip
|
|
const DELAY = 120;
|
|
|
|
@classic
|
|
@attributeBindings('style')
|
|
@classNames('absolute', 'z-999')
|
|
export default class KoenigLinkToolbar extends Component {
|
|
@inject config;
|
|
|
|
// public attrs
|
|
container = null;
|
|
editor = null;
|
|
linkRange = null;
|
|
selectedRange = null;
|
|
|
|
// internal attrs
|
|
url = 'http://example.com';
|
|
showToolbar = false;
|
|
top = null;
|
|
left = -1000;
|
|
right = null;
|
|
|
|
// private attrs
|
|
_canShowToolbar = true;
|
|
_eventListeners = null;
|
|
|
|
// closure actions
|
|
editLink() {}
|
|
|
|
/* computed properties -------------------------------------------------- */
|
|
|
|
@computed('showToolbar', 'top', 'left', 'right')
|
|
get style() {
|
|
let position = this.getProperties('top', 'left', 'right');
|
|
let styles = Object.keys(position).map((style) => {
|
|
if (position[style] !== null) {
|
|
return `${style}: ${position[style]}px`;
|
|
}
|
|
|
|
return undefined;
|
|
});
|
|
|
|
// ensure hidden toolbar is non-interactive
|
|
if (this.showToolbar) {
|
|
styles.push('pointer-events: auto !important');
|
|
// add margin-bottom so that there's no gap between the link and
|
|
// the toolbar to avoid closing when mouse moves between elements
|
|
styles.push(`padding-bottom: ${TOOLBAR_PADDING}px`);
|
|
} else {
|
|
styles.push('pointer-events: none !important');
|
|
}
|
|
|
|
return htmlSafe(styles.compact().join('; '));
|
|
}
|
|
|
|
/* lifecycle hooks ------------------------------------------------------ */
|
|
|
|
init() {
|
|
super.init(...arguments);
|
|
this._eventListeners = [];
|
|
}
|
|
|
|
didReceiveAttrs() {
|
|
super.didReceiveAttrs(...arguments);
|
|
|
|
// don't show popups if link edit or formatting toolbar is shown
|
|
// TODO: have a service for managing UI state?
|
|
if (this.linkRange || (this.selectedRange && !this.selectedRange.isCollapsed)) {
|
|
this._cancelTimeouts();
|
|
this.set('showToolbar', false);
|
|
this._canShowToolbar = false;
|
|
} else {
|
|
this._canShowToolbar = true;
|
|
}
|
|
}
|
|
|
|
didInsertElement() {
|
|
super.didInsertElement(...arguments);
|
|
|
|
let container = this.container;
|
|
container.dataset.kgHasLinkToolbar = true;
|
|
this._addEventListener(container, 'mouseover', this._handleMouseover);
|
|
this._addEventListener(container, 'mouseout', this._handleMouseout);
|
|
}
|
|
|
|
willDestroyElement() {
|
|
super.willDestroyElement(...arguments);
|
|
this._removeAllEventListeners();
|
|
}
|
|
|
|
@action
|
|
edit() {
|
|
// get range that covers link
|
|
let linkRange = this._getLinkRange();
|
|
this.editLink(linkRange, this._targetRect);
|
|
}
|
|
|
|
@action
|
|
remove() {
|
|
let editor = this.editor;
|
|
let linkRange = this._getLinkRange();
|
|
let editorRange = editor.range;
|
|
editor.run((postEditor) => {
|
|
postEditor.toggleMarkup('a', linkRange);
|
|
});
|
|
this.set('showToolbar', false);
|
|
editor.selectRange(editorRange);
|
|
}
|
|
|
|
/* private methods ------------------------------------------------------ */
|
|
|
|
_getLinkRange() {
|
|
if (!this._target) {
|
|
return;
|
|
}
|
|
|
|
const editor = this.editor;
|
|
const x = this._targetRect.x + this._targetRect.width / 2;
|
|
const y = this._targetRect.y + this._targetRect.height / 2;
|
|
|
|
try {
|
|
const position = editor.positionAtPoint(x, y);
|
|
const linkMarkup = position.marker && position.marker.markups.findBy('tagName', 'a');
|
|
if (linkMarkup) {
|
|
const linkRange = position.toRange().expandByMarker(marker => !!marker.markups.includes(linkMarkup));
|
|
return linkRange;
|
|
}
|
|
} catch (e) {
|
|
// don't throw because this isn't fatal
|
|
console.error(e); // eslint-disable-line
|
|
}
|
|
}
|
|
|
|
_handleMouseover(event) {
|
|
if (this._canShowToolbar) {
|
|
let target = getEventTargetMatchingTag('a', event.target, this.container);
|
|
if (target && target.isContentEditable && target.closest('[data-kg-has-link-toolbar=true]') === this.container) {
|
|
this._timeout = run.later(this, function () {
|
|
this._showToolbar(target, {x: event.clientX, y: event.clientY});
|
|
}, DELAY);
|
|
}
|
|
}
|
|
}
|
|
|
|
_handleMouseout(event) {
|
|
this._cancelTimeouts();
|
|
|
|
if (this.showToolbar) {
|
|
let toElement = event.toElement || event.relatedTarget;
|
|
if (toElement && !(toElement === this.element || toElement === this._target || toElement.closest(`#${this.elementId}`))) {
|
|
this.set('showToolbar', false);
|
|
}
|
|
}
|
|
}
|
|
|
|
_showToolbar(target, mousePos) {
|
|
// extract the href attribute value and convert it to absolute based
|
|
// on the configured blog url
|
|
this._target = target;
|
|
let href = target.getAttribute('href');
|
|
let blogUrl = this.config.blogUrl;
|
|
this.set('url', relativeToAbsolute(href, blogUrl));
|
|
this.set('showToolbar', true);
|
|
run.schedule('afterRender', this, function () {
|
|
this._positionToolbar(target, mousePos);
|
|
});
|
|
}
|
|
|
|
_cancelTimeouts() {
|
|
run.cancel(this._timeout);
|
|
if (this._elementObserver) {
|
|
this._elementObserver.cancel();
|
|
}
|
|
}
|
|
|
|
_positionToolbar(target, {x, y}) {
|
|
let containerRect = this.element.offsetParent.getBoundingClientRect();
|
|
|
|
// wrapped links can have multiple rects, find one closest to the pointer
|
|
// if we have a pointer position
|
|
if (x && y) {
|
|
let rects = Array.prototype.slice.call(target.getClientRects());
|
|
this._targetRect = rects.find((rect) => {
|
|
return rect.x - TOOLBAR_MARGIN <= x && x <= rect.x + rect.width + TOOLBAR_MARGIN &&
|
|
rect.y - TOOLBAR_MARGIN <= y && y <= rect.y + rect.height + TOOLBAR_MARGIN;
|
|
});
|
|
}
|
|
if (!this._targetRect) {
|
|
this._targetRect = target.getBoundingClientRect();
|
|
}
|
|
|
|
let {width, height} = this.element.getBoundingClientRect();
|
|
let newPosition = {};
|
|
|
|
// targetRect is relative to the viewport so we need to subtract the
|
|
// container measurements to get a position relative to the container
|
|
newPosition = {
|
|
top: this._targetRect.top - containerRect.top - height - TOOLBAR_MARGIN + TOOLBAR_PADDING,
|
|
left: this._targetRect.left - containerRect.left + this._targetRect.width / 2 - width / 2,
|
|
right: null
|
|
};
|
|
|
|
// don't overflow left boundary
|
|
if (newPosition.left < 0) {
|
|
newPosition.left = 0;
|
|
}
|
|
// same for right boundary
|
|
if (newPosition.left + width > containerRect.width) {
|
|
newPosition.left = null;
|
|
newPosition.right = 0;
|
|
}
|
|
|
|
// update the toolbar position
|
|
this.setProperties(newPosition);
|
|
}
|
|
|
|
_addStyleElement(styles) {
|
|
let styleElement = document.createElement('style');
|
|
styleElement.id = `${this.elementId}-style`;
|
|
styleElement.innerHTML = `#${this.elementId} > ul:after { ${styles} }`;
|
|
document.head.appendChild(styleElement);
|
|
}
|
|
|
|
_removeStyleElement() {
|
|
let styleElement = document.querySelector(`#${this.elementId}-style`);
|
|
if (styleElement) {
|
|
styleElement.remove();
|
|
}
|
|
}
|
|
|
|
_addEventListener(element, type, listener) {
|
|
let boundListener = run.bind(this, listener);
|
|
element.addEventListener(type, boundListener);
|
|
this._eventListeners.push([element, type, boundListener]);
|
|
}
|
|
|
|
_removeAllEventListeners() {
|
|
this._eventListeners.forEach(([element, type, listener]) => {
|
|
element.removeEventListener(type, listener);
|
|
});
|
|
}
|
|
}
|