import Component from '@ember/component'; import {computed} from '@ember/object'; import {htmlSafe} from '@ember/template'; 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({ attributeBindings: ['style'], classNames: ['absolute', 'z-999'], // 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 -------------------------------------------------- */ 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; // 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(); } } });