Ghost/ghost/admin/lib/koenig-editor/addon/components/koenig-toolbar.js
Kevin Ansfield 90a080e0b8 Update dependency eslint-plugin-ghost to v2.14.0 (#2441)
no issue

- bumped dependency
- fixed all new lint failures
- removed deprecated `ember-cli-eslint`
  - it was tying us to an old version of `eslint` resulting in missing rule definition errors when linting was run as part of `yarn dev` and `ember test`
  - we run linting separately in CI so we don't need linting to run _again_ on each of our ember test runs
2022-08-03 12:21:16 +01:00

336 lines
11 KiB
JavaScript

import Component from '@ember/component';
import classic from 'ember-classic-decorator';
import {action, computed} from '@ember/object';
import {attributeBindings, classNames} from '@ember-decorators/component';
import {htmlSafe} from '@ember/template';
import {run} from '@ember/runloop';
import {inject as service} from '@ember/service';
import {task, timeout} from 'ember-concurrency';
// initially rendered offscreen with opacity 0 so that sizing is available
// shown when passed in an uncollapsed selected range
// display is delayed until the mouse button is lifted
// positioned so that it's always fully within the editor container
// animation occurs via CSS transitions
// position is kept after hiding, it's made inoperable by CSS pointer-events
// pixels that should be added to separate toolbar from positioning rect
export const TOOLBAR_MARGIN = 15;
// pixels that should be added to the `left` property of the tick adjustment styles
// TODO: handle via CSS?
const TICK_ADJUSTMENT = 8;
@classic
@attributeBindings('style')
@classNames('absolute', 'z-999')
export default class KoenigToolbar extends Component {
@service feature;
// public attrs
basicOnly = false;
editor = null;
editorRange = null;
activeMarkupTagNames = null;
activeSectionTagNames = null;
// internal properties
showToolbar = false;
top = null;
left = -1000;
right = null;
// private properties
_isMouseDown = false;
_hasSelectedRange = false;
_onMousedownHandler = null;
_onMousemoveHandler = null;
_onMouseupHandler = null;
_onResizeHandler = null;
// closure actions
toggleMarkup() {}
toggleSection() {}
toggleHeaderSection() {}
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');
} else {
styles.push('pointer-events: none !important');
}
return htmlSafe(styles.compact().join('; '));
}
/* lifecycle hooks ------------------------------------------------------ */
init() {
super.init(...arguments);
// track mousedown/mouseup on the window so that we're sure to get the
// events even when they start outside of this component or end outside
// the window
this._onMousedownHandler = run.bind(this, this._handleMousedown);
window.addEventListener('mousedown', this._onMousedownHandler);
this._onMouseupHandler = run.bind(this, this._handleMouseup);
window.addEventListener('mouseup', this._onMouseupHandler);
this._onResizeHandler = run.bind(this, this._handleResize);
window.addEventListener('resize', this._onResizeHandler);
}
didReceiveAttrs() {
super.didReceiveAttrs(...arguments);
let range = this.editorRange;
if (range && !range.isCollapsed) {
this._hasSelectedRange = true;
} else {
this._hasSelectedRange = false;
}
if ((this._hasSelectedRange && !this.showToolbar) || (!this._hasSelectedRange && this.showToolbar)) {
this._toggleVisibility.perform();
}
}
willDestroyElement() {
super.willDestroyElement(...arguments);
this._removeStyleElement();
run.cancel(this._throttleResize);
window.removeEventListener('mousedown', this._onMousedownHandler);
window.removeEventListener('mousemove', this._onMousemoveHandler);
window.removeEventListener('mouseup', this._onMouseupHandler);
window.removeEventListener('resize', this._onResizeHandler);
}
@action
_toggleMarkup(markupName, event) {
event?.preventDefault();
if (markupName === 'em' && this.activeMarkupTagNames.isI) {
markupName = 'i';
}
this.toggleMarkup(markupName);
}
@action
toggleQuoteSection(event) {
event?.preventDefault();
let sectionName = 'blockquote';
if (this.activeSectionTagNames.isBlockquote) {
sectionName = 'aside';
} else if (this.activeSectionTagNames.isAside) {
sectionName = 'p';
}
const range = this.editorRange;
this.editor.run((postEditor) => {
this.toggleSection(sectionName, postEditor);
postEditor.setRange(range);
});
}
@action
_toggleHeaderSection(headingTagName, event) {
event?.preventDefault();
let range = this.editorRange;
this.editor.run((postEditor) => {
this.toggleHeaderSection(headingTagName, postEditor, {force: true});
postEditor.setRange(range);
});
}
@action
_editLink(event) {
event?.preventDefault();
this.editLink(this.editorRange);
}
/* private methods ------------------------------------------------------ */
@(task(function* (skipMousemove = false) {
// double-taps will often trigger before the selection change event so
// we want to keep the truthy mousemove skip around so that re-triggers
// within the 50ms timeout do not reset it
if (skipMousemove) {
this._skipMousemove = true;
}
// debounce for 50ms to account for "click to deselect" otherwise we
// run twice and the fade out animation jumps position
yield timeout(50);
// return early if the editorRange hasn't changed, this prevents
// re-rendering unnecessarily which can cause minor position jumps when
// styles are toggled because getBoundingClientRect on getSelection
// changes slightly depending on the style of selected text
if (this._hasSelectedRange && this.editorRange === this._lastRange) {
return;
}
// if we have a range, show the toolbnar once the mouse is lifted
if (this._hasSelectedRange && !this._isMouseDown) {
this._showToolbar(this._skipMousemove);
} else {
this._hideToolbar();
}
this._skipMousemove = false;
}).restartable())
_toggleVisibility;
_handleMousedown(event) {
// we only care about the left mouse button
if (event.which === 1) {
this._isMouseDown = true;
// prevent mousedown on toolbar buttons losing editor focus before the following click event can trigger the buttons behaviour
if (this.element.contains(event.target)) {
event.preventDefault();
}
}
}
_handleMousemove() {
if (this._hasSelectedRange && !this.showToolbar) {
this.set('showToolbar', true);
}
this._removeMousemoveHandler();
}
_removeMousemoveHandler() {
window.removeEventListener('mousemove', this._onMousemoveHandler);
this._onMousemoveHandler = null;
}
_handleMouseup(event) {
if (event.which === 1) {
this._isMouseDown = false;
// we want to skip the mousemove handler here because we know the
// selection (if there was one) was via the mouse and we don't want
// to wait for another mousemove before showing the toolbar
this._toggleVisibility.perform(true);
}
}
_handleResize() {
if (this.showToolbar) {
this._throttleResize = run.throttle(this, this._positionToolbar, 100);
}
}
_showToolbar(skipMousemove) {
this._positionToolbar();
if (skipMousemove) {
this.set('showToolbar', true);
}
if (!this.showToolbar && !this._onMousemoveHandler) {
this._onMousemoveHandler = run.bind(this, this._handleMousemove);
window.addEventListener('mousemove', this._onMousemoveHandler);
}
// track displayed range so that we don't re-position unnecessarily
this._lastRange = this.editorRange;
}
_hideToolbar() {
if (!this.isDestroyed || !this.isDestroying) {
this.set('showToolbar', false);
}
this._lastRange = null;
this._removeMousemoveHandler();
}
_positionToolbar() {
let containerRect = this.element.offsetParent.getBoundingClientRect();
let range = window.getSelection().getRangeAt(0);
let rangeRect = range.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(tickPosition);
}
// update the toolbar position
this.setProperties(newPosition);
}
_addStyleElement(tickPosition) {
let beforeStyle = `left: calc(${tickPosition}% - ${TICK_ADJUSTMENT + 2}px);`;
let afterStyle = `left: calc(${tickPosition}% - ${TICK_ADJUSTMENT}px);`;
let styleElement = document.createElement('style');
styleElement.id = `${this.elementId}-style`;
styleElement.innerHTML = `
#${this.elementId} > ul:before { ${beforeStyle} }
#${this.elementId} > ul:after { ${afterStyle} }
`;
document.head.appendChild(styleElement);
}
_removeStyleElement() {
let styleElement = document.querySelector(`#${this.elementId}-style`);
if (styleElement) {
styleElement.remove();
}
}
}