import Component from '@ember/component'; import layout from '../templates/components/koenig-link-input'; import {TOOLBAR_MARGIN} from './koenig-toolbar'; import {computed} from '@ember/object'; import {htmlSafe} from '@ember/string'; import {run} from '@ember/runloop'; // pixels that should be added to the `left` property of the tick adjustment styles // TODO: handle via CSS? const TICK_ADJUSTMENT = 8; // TODO: move to a util function getScrollParent(node) { const isElement = node instanceof HTMLElement; const overflowY = isElement && window.getComputedStyle(node).overflowY; const isScrollable = overflowY !== 'visible' && overflowY !== 'hidden'; if (!node) { return null; } else if (isScrollable && node.scrollHeight >= node.clientHeight) { return node; } return getScrollParent(node.parentNode) || document.body; } export default Component.extend({ layout, attributeBindings: ['style'], classNames: ['kg-input-bar', 'absolute', 'z-999'], // public attrs editor: null, linkRange: null, selectedRange: null, // internal properties top: null, left: null, right: null, href: '', // private properties _selectedRange: null, _windowRange: null, _onMousedownHandler: null, _onMouseupHandler: null, // closure actions cancel() {}, /* computed properties -------------------------------------------------- */ style: computed('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`; } }); return htmlSafe(styles.compact().join('; ')); }), /* lifecycle hooks ------------------------------------------------------ */ init() { this._super(...arguments); // record the range now because the property is bound and will update // as we make changes whilst calculating the link position this._selectedRange = this.get('selectedRange'); this._linkRange = this.get('linkRange'); // grab a window range so that we can use getBoundingClientRect. Using // document.createRange is more efficient than doing editor.setRange // because it doesn't trigger all of the selection changing side-effects // TODO: extract MobiledocRange->NativeRange into a util let editor = this.get('editor'); let cursor = editor.cursor; let {head, tail} = this._linkRange; let {node: headNode, offset: headOffset} = cursor._findNodeForPosition(head); let {node: tailNode, offset: tailOffset} = cursor._findNodeForPosition(tail); let range = document.createRange(); range.setStart(headNode, headOffset); range.setEnd(tailNode, tailOffset); this._windowRange = range; // wait until rendered to position so that we have access to this.element run.schedule('afterRender', this, function () { this._positionToolbar(); this._focusInput(); }); // grab an existing href value if there is one this._getHrefFromMarkup(); // watch the window for mousedown events so that we can close the menu // when we detect a click outside this._onMousedownHandler = run.bind(this, this._handleMousedown); window.addEventListener('mousedown', this._onMousedownHandler); // watch for keydown events so that we can close the menu on Escape this._onKeydownHandler = run.bind(this, this._handleKeydown); window.addEventListener('keydown', this._onKeydownHandler); }, willDestroyElement() { this._super(...arguments); this._removeStyleElement(); window.removeEventListener('mousedown', this._onMousedownHandler); window.removeEventListener('keydown', this._onKeydownHandler); }, actions: { inputKeydown(event) { if (event.code === 'Enter') { // prevent Enter from triggering in the editor and removing text event.preventDefault(); let href = this.get('href'); // create a single editor runloop here so that we don't get // separate remove and replace ops pushed onto the undo stack this.get('editor').run((postEditor) => { if (href) { this._replaceLink(href, postEditor); } else { this._removeLinks(postEditor); } }); this._cancelAndReselect(); } }, clear() { this.set('href', ''); this._focusInput(); } }, // if we have a single link or a slice of a single link selected, grab the // href and adjust our linkRange to encompass the whole link _getHrefFromMarkup() { let {headMarker, tailMarker} = this._linkRange; if (headMarker === tailMarker || headMarker.next === tailMarker) { let linkMarkup = tailMarker.markups.findBy('tagName', 'a'); if (linkMarkup) { this.set('href', linkMarkup.attributes.href); this._linkRange = this._linkRange.expandByMarker(marker => !!marker.markups.includes(linkMarkup)); } } }, _replaceLink(href, postEditor) { this._removeLinks(postEditor); let linkMarkup = postEditor.builder.createMarkup('a', {href}); postEditor.toggleMarkup(linkMarkup, this._linkRange); }, // loop over all markers that are touched by linkRange, removing any 'a' // markups on them to clear all links _removeLinks(postEditor) { let {headMarker, tailMarker} = this.get('linkRange'); let curMarker = headMarker; while (curMarker && curMarker !== tailMarker.next) { curMarker.markups.filterBy('tagName', 'a').forEach((markup) => { curMarker.removeMarkup(markup); postEditor._markDirty(curMarker); }); curMarker = curMarker.next; } }, _cancelAndReselect() { this.cancel(); if (this._selectedRange) { this.get('editor').selectRange(this._selectedRange); } }, _focusInput() { let scrollParent = getScrollParent(this.element); let scrollTop = scrollParent.scrollTop; this.element.querySelector('input').focus(); // reset the scroll position to avoid jumps // TODO: why does the input focus cause a scroll to the bottom of the doc? scrollParent.scrollTop = scrollTop; }, // TODO: largely shared with {{koenig-toolbar}} code - extract to a shared util? _positionToolbar() { let containerRect = this.element.parentNode.getBoundingClientRect(); let rangeRect = this._windowRange.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}:before, #${this.elementId}:after { ${styles} }`; document.head.appendChild(styleElement); }, _removeStyleElement() { let styleElement = document.querySelector(`#${this.elementId}-style`); if (styleElement) { styleElement.remove(); } }, _handleMousedown(event) { if (!event.target.closest(`#${this.elementId}`)) { // no need to re-select for mouse clicks this.cancel(); } }, _handleKeydown(event) { if (event.code === 'Escape') { this._cancelAndReselect(); } } });