mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-28 22:43:30 +03:00
81ec6729c8
no issue The toolbar display/positioning logic was recently changed so that the toolbar is shown when a `saveAsSnippet` action is passed in. However the `_setToolbarProperties` method wasn't taking into account the toolbar element not being present when in editing mode such as when a block-editable card (markdown, html, code) is created. - remove conditional logic that may change over time and replace it with checks for the toolbar element existing
420 lines
14 KiB
JavaScript
420 lines
14 KiB
JavaScript
import Browser from 'mobiledoc-kit/utils/browser';
|
|
import Component from '@ember/component';
|
|
import {computed} from '@ember/object';
|
|
import {htmlSafe} from '@ember/string';
|
|
import {run} from '@ember/runloop';
|
|
import {inject as service} from '@ember/service';
|
|
|
|
const TICK_HEIGHT = 8;
|
|
|
|
export default Component.extend({
|
|
koenigUi: service(),
|
|
|
|
attributeBindings: ['_style:style'],
|
|
classNameBindings: ['selectedClass'],
|
|
|
|
// attrs
|
|
editor: null,
|
|
icon: null,
|
|
iconClass: 'ih5 absolute stroke-midgrey-l2 mt1 nl15 kg-icon',
|
|
toolbar: null,
|
|
isSelected: false,
|
|
isEditing: false,
|
|
hasEditMode: true,
|
|
headerOffset: 0,
|
|
showSelectedOutline: true,
|
|
|
|
// properties
|
|
showToolbar: false,
|
|
toolbarWidth: 0,
|
|
toolbarHeight: 0,
|
|
|
|
// internal properties
|
|
_lastIsEditing: false,
|
|
|
|
// closure actions
|
|
selectCard() {},
|
|
deselectCard() {},
|
|
editCard() {},
|
|
// hooks - when attached these will be fired on the individual card components
|
|
onSelect() {},
|
|
onDeselect() {},
|
|
onEnterEdit() {},
|
|
onLeaveEdit() {},
|
|
|
|
shouldShowToolbar: computed('showToolbar', 'koenigUi.{captionHasFocus,isDragging,inputHasFocus}', function () {
|
|
return this.showToolbar
|
|
&& !this.koenigUi.captionHasFocus
|
|
&& !this.koenigUi.inputHasFocus
|
|
&& !this.koenigUi.isDragging;
|
|
}),
|
|
|
|
toolbarStyle: computed('shouldShowToolbar', 'toolbarWidth', 'toolbarHeight', function () {
|
|
let showToolbar = this.shouldShowToolbar;
|
|
let width = this.toolbarWidth;
|
|
let height = this.toolbarHeight;
|
|
let styles = [];
|
|
|
|
styles.push(`top: -${height}px`);
|
|
styles.push(`left: calc(50% - ${width / 2}px)`);
|
|
|
|
if (!showToolbar) {
|
|
styles.push('pointer-events: none !important');
|
|
}
|
|
|
|
return htmlSafe(styles.join('; '));
|
|
}),
|
|
|
|
iconTop: computed('headerOffset', function () {
|
|
return this.headerOffset + 24;
|
|
}),
|
|
|
|
selectedClass: computed('isSelected', 'showSelectedOutline', function () {
|
|
return this.isSelected && this.showSelectedOutline ? 'kg-card-selected' : '';
|
|
}),
|
|
|
|
didReceiveAttrs() {
|
|
this._super(...arguments);
|
|
|
|
let isSelected = this.isSelected;
|
|
let isEditing = this.isEditing;
|
|
let hasEditMode = this.hasEditMode;
|
|
|
|
// TODO: replace with Spirit classes
|
|
let baseStyles = 'cursor: default; caret-color: auto;';
|
|
this.set('_style', htmlSafe(`${baseStyles} ${this.style || ''}`));
|
|
|
|
if (isSelected !== this._lastIsSelected) {
|
|
if (isSelected) {
|
|
this._fireWhenRendered(this._onSelect);
|
|
} else {
|
|
this._fireWhenRendered(this._onDeselect);
|
|
}
|
|
}
|
|
|
|
if (isEditing !== this._lastIsEditing) {
|
|
if (!hasEditMode) {
|
|
isEditing = false;
|
|
} else if (isEditing) {
|
|
this._onEnterEdit();
|
|
} else {
|
|
this._onLeaveEdit();
|
|
}
|
|
}
|
|
|
|
// show the toolbar immediately if it changes whilst the card is selected
|
|
// caters for situations such as only showing image style buttons once an
|
|
// image has been uploaded
|
|
if (isSelected && this._lastIsSelected && this.toolbar && this.toolbar !== this._lastToolbar) {
|
|
run.scheduleOnce('afterRender', this, this._showToolbar);
|
|
}
|
|
|
|
if (isSelected && this._lastIsSelected && this.saveAsSnippet && this.saveAsSnippet !== this._lastSaveAsSnippet) {
|
|
run.scheduleOnce('afterRender', this, this._showToolbar);
|
|
}
|
|
|
|
this._lastIsSelected = isSelected;
|
|
this._lastIsEditing = isEditing;
|
|
this._lastToolbar = this.toolbar;
|
|
this._lastSaveAsSnippet = this.saveAsSnippet;
|
|
},
|
|
|
|
didInsertElement() {
|
|
this._super(...arguments);
|
|
this._setToolbarProperties();
|
|
this._createMutationObserver(
|
|
this.element,
|
|
run.bind(this, this._inputFocus),
|
|
run.bind(this, this._inputBlur)
|
|
);
|
|
},
|
|
|
|
willDestroyElement() {
|
|
this._super(...arguments);
|
|
window.removeEventListener('keydown', this._onKeydownHandler);
|
|
window.removeEventListener('click', this._onClickHandler);
|
|
this._removeMousemoveHandler();
|
|
|
|
if (this._mutationObserver) {
|
|
this._mutationObserver.disconnect();
|
|
}
|
|
|
|
if (this._hasDisabledContenteditable) {
|
|
this.editor.element.contentEditable = true;
|
|
}
|
|
},
|
|
|
|
mouseDown(event) {
|
|
let {isSelected, isEditing, hasEditMode} = this;
|
|
|
|
// if we perform an action we want to prevent the mousedown from
|
|
// triggering a cursor position change which can result in multiple
|
|
// card select calls getting the component into an odd state. We also
|
|
// manually show the toolbar so that we're not relying on mousemove
|
|
if (!isSelected && !isEditing) {
|
|
this.selectCard();
|
|
this.set('showToolbar', true);
|
|
|
|
// in most situations we want to prevent default behaviour which
|
|
// can cause an underlying cursor position change but inputs and
|
|
// textareas are different and we want the focus to move to them
|
|
// immediately when clicked
|
|
let targetTagName = event.target.tagName;
|
|
let allowedTagNames = ['INPUT', 'TEXTAREA'];
|
|
let allowClickthrough = !!event.target.closest('[data-kg-allow-clickthrough]');
|
|
if (!allowedTagNames.includes(targetTagName) && !allowClickthrough) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
// don't trigger edit mode immediately
|
|
this._skipMouseUp = true;
|
|
}
|
|
|
|
// don't trigger select->edit transition for clicks in the caption or
|
|
// when clicking out of the caption
|
|
if (isSelected && hasEditMode) {
|
|
let allowClickthrough = !!event.target.closest('[data-kg-allow-clickthrough]');
|
|
if (allowClickthrough || this.koenigUi.captionHasFocus) {
|
|
this._skipMouseUp = true;
|
|
}
|
|
}
|
|
},
|
|
|
|
// lazy-click to enter edit mode
|
|
mouseUp(event) {
|
|
let {isSelected, isEditing, hasEditMode, _skipMouseUp} = this;
|
|
|
|
if (!_skipMouseUp && hasEditMode && isSelected && !isEditing && !this.koenigUi.isDragging) {
|
|
this.editCard();
|
|
this.set('showToolbar', true);
|
|
event.preventDefault();
|
|
}
|
|
|
|
this._skipMouseUp = false;
|
|
},
|
|
|
|
doubleClick() {
|
|
let allowClickthrough = !!event.target.closest('[data-kg-allow-clickthrough]');
|
|
if (this.hasEditMode && !this.isEditing && !allowClickthrough) {
|
|
this.editCard();
|
|
this.set('showToolbar', true);
|
|
}
|
|
},
|
|
|
|
_onSelect() {
|
|
this._fireWhenRendered(this._showToolbar);
|
|
this._showToolbar();
|
|
this.onSelect();
|
|
|
|
this._onClickHandler = run.bind(this, this._handleClick);
|
|
window.addEventListener('click', this._onClickHandler);
|
|
},
|
|
|
|
_onDeselect() {
|
|
window.removeEventListener('click', this._onClickHandler);
|
|
this._hideToolbar();
|
|
this.onDeselect();
|
|
},
|
|
|
|
_onEnterEdit() {
|
|
// don't register key down handlers immediately otherwise we can interfere
|
|
// with keyboard events that have just put the card into edit mode
|
|
run.later(this, function () {
|
|
if (this.isEditing && !this.isDestroyed && !this.isDestroying) {
|
|
this._onKeydownHandler = run.bind(this, this._handleKeydown);
|
|
window.addEventListener('keydown', this._onKeydownHandler);
|
|
}
|
|
}, 20);
|
|
|
|
// store a copy of the payload for later comparison
|
|
this._snapshotPayload = JSON.stringify(this.payload);
|
|
|
|
this.onEnterEdit();
|
|
},
|
|
|
|
_onLeaveEdit() {
|
|
window.removeEventListener('keydown', this._onKeydownHandler);
|
|
|
|
// if the payload has changed since entering edit mode then store a snapshot
|
|
let newPayload = JSON.stringify(this.payload);
|
|
if (newPayload !== this._snapshotPayload) {
|
|
this.editor.run(() => {
|
|
this.saveCard(this.payload);
|
|
});
|
|
}
|
|
|
|
delete this._snapshotPayload;
|
|
|
|
this.onLeaveEdit();
|
|
},
|
|
|
|
_setToolbarProperties() {
|
|
// select the last toolbar in the element because card contents/captions
|
|
// may have their own toolbar elements
|
|
let toolbar = this.element?.querySelector(':scope > [data-kg-toolbar="true"]');
|
|
|
|
if (!toolbar) {
|
|
return;
|
|
}
|
|
|
|
let {width, height} = toolbar.getBoundingClientRect();
|
|
|
|
this.setProperties({
|
|
toolbarWidth: width,
|
|
toolbarHeight: height + TICK_HEIGHT
|
|
});
|
|
},
|
|
|
|
_showToolbar() {
|
|
// only show a toolbar if we have one
|
|
if (this.toolbar || this.saveAsSnippet) {
|
|
this._setToolbarProperties();
|
|
|
|
if (!this.showToolbar && !this._onMousemoveHandler) {
|
|
this._onMousemoveHandler = run.bind(this, this._handleMousemove);
|
|
window.addEventListener('mousemove', this._onMousemoveHandler);
|
|
}
|
|
}
|
|
},
|
|
|
|
_hideToolbar() {
|
|
this.set('showToolbar', false);
|
|
this._removeMousemoveHandler();
|
|
},
|
|
|
|
_handleKeydown(event) {
|
|
if (
|
|
this.isEditing
|
|
&& event.key === 'Escape'
|
|
|| (Browser.isMac() && event.key === 'Enter' && event.metaKey)
|
|
|| (!Browser.isMac() && event.key === 'Enter' && event.ctrlKey)
|
|
) {
|
|
// run the select card routine with isEditing=false to exit edit mode
|
|
this.selectCard(false);
|
|
event.preventDefault();
|
|
}
|
|
},
|
|
|
|
// exit edit mode any time we have a click outside of the card unless it's
|
|
// a click inside one of our modals or on the plus menu
|
|
_handleClick(event) {
|
|
let {target, path} = event;
|
|
|
|
// Safari doesn't expose MouseEvent.path
|
|
if (!path) {
|
|
path = event.composedPath();
|
|
}
|
|
|
|
let searchPath = function (selector) {
|
|
return element => element.closest && element.closest(selector);
|
|
};
|
|
|
|
// check if the click was in the card, on the plus menu, or on a modal
|
|
if (this.element.contains(target)
|
|
|| path.find(searchPath(`#${this.element.id}`))
|
|
|| path.find(searchPath('[data-kg="plus-menu"]'))
|
|
|| path.find(searchPath('.liquid-destination'))) {
|
|
return;
|
|
}
|
|
|
|
// if an element in the editor is clicked then cursor placement will
|
|
// deselect or keep this card selected as necessary
|
|
if (this.editor.element.contains(target)) {
|
|
return;
|
|
}
|
|
|
|
this.deselectCard();
|
|
},
|
|
|
|
_handleMousemove() {
|
|
if (!this.showToolbar) {
|
|
this.set('showToolbar', true);
|
|
this._removeMousemoveHandler();
|
|
}
|
|
},
|
|
|
|
_removeMousemoveHandler() {
|
|
window.removeEventListener('mousemove', this._onMousemoveHandler);
|
|
this._onMousemoveHandler = null;
|
|
},
|
|
|
|
// convenience method for when we only want to run a method when our
|
|
// elements have been rendered
|
|
_fireWhenRendered(method) {
|
|
if (this.element) {
|
|
run.bind(this, method)();
|
|
} else {
|
|
run.scheduleOnce('afterRender', this, method);
|
|
}
|
|
},
|
|
|
|
// Firefox can't handle inputs inside of a contenteditable element so we
|
|
// need to watch for any inputs being added so that we can attach focus/blur
|
|
// event handlers that can disable contenteditable on the editor element
|
|
_createMutationObserver(target, focusCallback, blurCallback) {
|
|
function addInputFocusListeners(mutation) {
|
|
function addInputFocusListener(element) {
|
|
if (!inputElements.includes(element)) {
|
|
inputElements.push(element);
|
|
element.addEventListener('focus', focusCallback, false);
|
|
element.addEventListener('blur', blurCallback, false);
|
|
}
|
|
}
|
|
|
|
if (mutation.type === 'childList') {
|
|
Array.prototype.forEach.call(
|
|
mutation.target.querySelectorAll('input[type="text"]'),
|
|
addInputFocusListener
|
|
);
|
|
}
|
|
}
|
|
|
|
function removeFromElements(element) {
|
|
inputElements.splice(inputElements.indexOf(element), 1);
|
|
}
|
|
|
|
function removeInputFocusListener(element) {
|
|
element.removeEventListener('focus', focusCallback, false);
|
|
element.removeEventListener('blur', blurCallback, false);
|
|
removeFromElements(element);
|
|
}
|
|
|
|
function mutationObserved(mutations) {
|
|
mutations.forEach(addInputFocusListeners);
|
|
}
|
|
|
|
function createMutationObserver(mutationTarget) {
|
|
let config = {
|
|
childList: true,
|
|
subtree: true
|
|
};
|
|
|
|
let observer = new MutationObserver(mutationObserved);
|
|
observer.observe(mutationTarget, config); // eslint-disable-line ghost/ember/no-observers
|
|
return observer;
|
|
}
|
|
|
|
let inputElements = [];
|
|
let observer = createMutationObserver(target);
|
|
|
|
return {
|
|
disconnect() {
|
|
if ('disconnect' in observer) {
|
|
observer.disconnect(); // eslint-disable-line ghost/ember/no-observers
|
|
inputElements.forEach(removeInputFocusListener);
|
|
}
|
|
}
|
|
};
|
|
},
|
|
|
|
_inputFocus() {
|
|
this._hasDisabledContenteditable = true;
|
|
this.editor.element.contentEditable = false;
|
|
},
|
|
|
|
_inputBlur() {
|
|
this._hasDisabledContenteditable = false;
|
|
this.editor.element.contentEditable = true;
|
|
}
|
|
});
|