mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-19 16:42:17 +03:00
e34c6a14c4
refs https://github.com/TryGhost/Ghost/issues/9623 - header text expansion skip for headers with the same level didn't take into account that the toolbar should actually toggle the heading on/off
300 lines
10 KiB
JavaScript
300 lines
10 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() {},
|
|
toggleHeaderSection() {},
|
|
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.editor.run((postEditor) => {
|
|
this.toggleSection(sectionName, postEditor);
|
|
postEditor.setRange(range);
|
|
});
|
|
},
|
|
|
|
toggleHeaderSection(headingTagName) {
|
|
let range = this.editorRange;
|
|
this.editor.run((postEditor) => {
|
|
this.toggleHeaderSection(headingTagName, postEditor, {force: true});
|
|
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();
|
|
}
|
|
}
|
|
});
|