mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-22 18:31:57 +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
305 lines
10 KiB
JavaScript
305 lines
10 KiB
JavaScript
import Component from '@ember/component';
|
|
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({
|
|
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;
|
|
}
|
|
},
|
|
|
|
_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();
|
|
}
|
|
}
|
|
});
|