mirror of
https://github.com/TryGhost/Ghost-Admin.git
synced 2023-12-14 02:33:04 +01:00
no issue Keeps component JS backing files and template files in the same directory which avoids hunting across directories when working with components. Also lets you see all components when looking at one directory, whereas previously template-only or js-only components may not have been obvious without looking at both directories. - ran [codemod](https://github.com/ember-codemods/ember-component-template-colocation-migrator/) for app-level components - manually moved in-repo-addon component templates in `lib/koenig-editor` - removed all explicit `layout` imports as JS/template associations are now made at build-time removing the need for them - updated `.embercli` to default to new flat component structure
251 lines
8.3 KiB
JavaScript
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/string';
|
|
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);
|
|
});
|
|
}
|
|
});
|