Ghost/ghost/admin/lib/koenig-editor/addon/components/koenig-link-input.js
Kevin Ansfield 09435ecf76 Co-located component template files
no issue

Keeps component JS backing files and template files in the same directory which avoids hunting across directories when working with components. Also lets you see all components when looking at one directory, whereas previously template-only or js-only components may not have been obvious without looking at both directories.

- ran [codemod](https://github.com/ember-codemods/ember-component-template-colocation-migrator/) for app-level components
- manually moved in-repo-addon component templates in `lib/koenig-editor`
- removed all explicit `layout` imports as JS/template associations are now made at build-time removing the need for them
- updated `.embercli` to default to new flat component structure
2020-05-18 13:14:08 +01:00

275 lines
9.5 KiB
JavaScript

import Component from '@ember/component';
import relativeToAbsolute from '../lib/relative-to-absolute';
import {TOOLBAR_MARGIN} from './koenig-toolbar';
import {computed} from '@ember/object';
import {getLinkMarkupFromRange} from '../utils/markup-utils';
import {htmlSafe} from '@ember/string';
import {run} from '@ember/runloop';
import {inject as service} from '@ember/service';
// 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({
config: service(),
attributeBindings: ['style'],
classNames: ['kg-input-bar', 'absolute', 'z-999'],
// public attrs
editor: null,
linkRange: null,
linkRect: 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.selectedRange;
this._linkRange = this.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.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.key === 'Enter') {
// prevent Enter from triggering in the editor and removing text
event.preventDefault();
let href = relativeToAbsolute(this.href, this.config.get('blogUrl'));
this.set('href', href);
// create a single editor runloop here so that we don't get
// separate remove and replace ops pushed onto the undo stack
this.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 linkMarkup = getLinkMarkupFromRange(this._linkRange);
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.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.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.offsetParent.getBoundingClientRect();
let rangeRect = this.linkRect || 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.key === 'Escape') {
this._cancelAndReselect();
}
}
});