mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-23 10:53:34 +03:00
09435ecf76
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
275 lines
9.5 KiB
JavaScript
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();
|
|
}
|
|
}
|
|
});
|