Ghost/ghost/admin/lib/koenig-editor/addon/components/koenig-snippet-input.js

232 lines
7.8 KiB
JavaScript
Raw Normal View History

import Component from '@glimmer/component';
import getScrollParent from '../utils/get-scroll-parent';
import {TOOLBAR_MARGIN} from './koenig-toolbar';
import {action} from '@ember/object';
import {guidFor} from '@ember/object/internals';
import {htmlSafe} from '@ember/template';
import {run} from '@ember/runloop';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
// pixels that should be added to the `left` property of the tick adjustment styles
// TODO: handle via CSS?
const TICK_ADJUSTMENT = 8;
export default class KoenigSnippetInputComponent extends Component {
@service koenigUi;
@tracked name = '';
@tracked style = htmlSafe('');
get snippetsWithGroup() {
const snippets = this.args.snippets;
return [{
groupName: 'Replace existing',
options: snippets
}];
}
constructor() {
super(...arguments);
// hide any other toolbars
this.koenigUi.inputHasFocus = true;
// record the range now because the property is bound and will update
// when the selection changes
this._snippetRange = this.args.snippetRange;
// 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.args.editor;
let cursor = editor.cursor;
let {head, tail} = this.args.snippetRange;
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;
// 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);
this.scrollParent = getScrollParent(editor.element);
this.scrollTop = this.scrollParent.scrollTop;
}
get snippetMobiledoc() {
let {snippetRange, editor} = this.args;
return editor.serializePost(editor.post.trimTo(snippetRange), 'mobiledoc');
}
willDestroy() {
super.willDestroy?.(...arguments);
this.koenigUi.inputHasFocus = false;
window.removeEventListener('mousedown', this._onMousedownHandler);
window.removeEventListener('keydown', this._onKeydownHandler);
this._removeStyleElement();
}
@action
selectSnippet(snippetName) {
const snippetNameLC = snippetName.trim().toLowerCase();
const existingSnippet = this.args.snippets.find(snippet => snippet.name.toLowerCase() === snippetNameLC);
if (existingSnippet) {
this.replaceSnippet(existingSnippet);
} else {
this.createSnippet(snippetName);
}
}
createSnippet(name) {
this.args.save({
name,
mobiledoc: this.snippetMobiledoc
}).then(() => {
this.args.cancel();
});
}
replaceSnippet(snippet) {
this.args.update(
snippet,
{mobiledoc: this.snippetMobiledoc}
);
// close the snippet input
this.args.cancel();
}
@action
nameKeydown(event) {
if (event.key === 'Enter') {
// prevent Enter from triggering in the editor and removing text
event.preventDefault();
// convert selection into a mobiledoc document
let {snippetRange, editor} = this.args;
let mobiledoc = editor.serializePost(editor.post.trimTo(snippetRange), 'mobiledoc');
this.args.save({
name: event.target.value,
mobiledoc
}).then(() => {
this.args.cancel();
});
}
}
@action
nameInput(name) {
this.name = name;
}
// TODO: largely shared with {{koenig-toolbar}} and {{koenig-link-input}} - extract to a shared util?
@action
registerAndPositionElement(element) {
this.scrollParent.scrollTop = this.scrollTop;
element.id = guidFor(element);
this.element = element;
let containerRect = this.element.offsetParent.getBoundingClientRect();
let rangeRect = this.args.snippetRect || 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.style = htmlSafe(Object.keys(newPosition).map((style) => {
if (newPosition[style] !== null) {
return `${style}: ${newPosition[style]}px`;
}
}).compact().join('; '));
}
_handleMousedown(event) {
const isOutsideElement = this.element && !event.target.closest(this.element.id);
const isOutsideDropdown = !event.target.closest('.ember-basic-dropdown-content');
if (isOutsideElement && isOutsideDropdown) {
this.args.cancel();
}
}
_handleKeydown(event) {
if (event.key === 'Escape') {
this._cancelAndReselect();
}
}
_cancelAndReselect() {
this.args.cancel();
if (this._snippetRange) {
this.args.editor.selectRange(this._snippetRange);
}
}
_addStyleElement(styles) {
let styleElement = document.createElement('style');
styleElement.id = `${this.element.id}-style`;
styleElement.innerHTML = `#${this.element.id}:before, #${this.element.id}:after { ${styles} }`;
document.head.appendChild(styleElement);
}
_removeStyleElement() {
let styleElement = document.querySelector(`#${this.element.id}-style`);
if (styleElement) {
styleElement.remove();
}
}
}