mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-23 02:41:50 +03:00
d760e5633c
refs https://github.com/TryGhost/Ghost/issues/9623 - maintains easy reversibility by keeping text selected and toolbar shown in the same position to avoid jumping buttons when font size or other formatting changes
291 lines
9.7 KiB
JavaScript
291 lines
9.7 KiB
JavaScript
import Component from '@ember/component';
|
|
import layout from '../templates/components/koenig-toolbar';
|
|
import {computed} from '@ember/object';
|
|
import {htmlSafe} from '@ember/string';
|
|
import {run} from '@ember/runloop';
|
|
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;
|
|
|
|
export default Component.extend({
|
|
layout,
|
|
|
|
attributeBindings: ['style'],
|
|
classNames: ['absolute', 'z-999'],
|
|
|
|
// public attrs
|
|
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() {},
|
|
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');
|
|
} else {
|
|
styles.push('pointer-events: none !important');
|
|
}
|
|
|
|
return htmlSafe(styles.compact().join('; '));
|
|
}),
|
|
|
|
/* lifecycle hooks ------------------------------------------------------ */
|
|
|
|
init() {
|
|
this._super(...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() {
|
|
this._super(...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() {
|
|
this._super(...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);
|
|
},
|
|
|
|
actions: {
|
|
toggleMarkup(markupName) {
|
|
if (markupName === 'em' && this.activeMarkupTagNames.isI) {
|
|
markupName = 'i';
|
|
}
|
|
|
|
this.toggleMarkup(markupName);
|
|
},
|
|
|
|
toggleSection(sectionName) {
|
|
let range = this.editorRange;
|
|
this.toggleSection(sectionName);
|
|
this.editor.run((postEditor) => {
|
|
postEditor.setRange(range);
|
|
});
|
|
},
|
|
|
|
editLink() {
|
|
this.editLink(this.editorRange);
|
|
}
|
|
},
|
|
|
|
/* private methods ------------------------------------------------------ */
|
|
|
|
_toggleVisibility: 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(),
|
|
|
|
_handleMousedown(event) {
|
|
// we only care about the left mouse button
|
|
if (event.which === 1) {
|
|
this._isMouseDown = true;
|
|
}
|
|
},
|
|
|
|
_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() {
|
|
this.set('showToolbar', false);
|
|
this._lastRange = null;
|
|
this._removeMousemoveHandler();
|
|
},
|
|
|
|
_positionToolbar() {
|
|
let containerRect = this.element.parentNode.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(`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} > ul:after { ${styles} }`;
|
|
document.head.appendChild(styleElement);
|
|
},
|
|
|
|
_removeStyleElement() {
|
|
let styleElement = document.querySelector(`#${this.elementId}-style`);
|
|
if (styleElement) {
|
|
styleElement.remove();
|
|
}
|
|
}
|
|
});
|