Ghost/ghost/admin/lib/koenig-editor/addon/components/koenig-link-toolbar.js

251 lines
8.3 KiB
JavaScript

import Component from '@ember/component';
import relativeToAbsolute from '../lib/relative-to-absolute';
import {computed} from '@ember/object';
import {getEventTargetMatchingTag} from 'mobiledoc-kit/utils/element-utils';
import {htmlSafe} from '@ember/template';
import {run} from '@ember/runloop';
import {inject as service} from '@ember/service';
// 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;
export default Component.extend({
config: service(),
attributeBindings: ['style'],
classNames: ['absolute', 'z-999'],
// 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 -------------------------------------------------- */
style: computed('showToolbar', '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`;
}
});
// 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() {
this._super(...arguments);
this._eventListeners = [];
},
didReceiveAttrs() {
this._super(...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() {
this._super(...arguments);
let container = this.container;
container.dataset.kgHasLinkToolbar = true;
this._addEventListener(container, 'mouseover', this._handleMouseover);
this._addEventListener(container, 'mouseout', this._handleMouseout);
},
willDestroyElement() {
this._super(...arguments);
this._removeAllEventListeners();
},
/* actions -------------------------------------------------------------- */
actions: {
edit() {
// get range that covers link
let linkRange = this._getLinkRange();
this.editLink(linkRange, this._targetRect);
},
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;
}
let editor = this.editor;
let x = this._targetRect.x + this._targetRect.width / 2;
let y = this._targetRect.y + this._targetRect.height / 2;
let position = editor.positionAtPoint(x, y);
let linkMarkup = position.marker && position.marker.markups.findBy('tagName', 'a');
if (linkMarkup) {
let linkRange = position.toRange().expandByMarker(marker => !!marker.markups.includes(linkMarkup));
return linkRange;
}
},
_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.get('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);
});
}
});