Ghost/ghost/admin/app/modifiers/movable.js
Kevin Ansfield cd6d167f77 Added repositioning of settings panel when it's size adjusts
refs https://github.com/TryGhost/Team/issues/1219

- added optional `adjustOnResize` argument to `{{movable}}` modifier
  - when the movable element's size changes (based on a `ResizeObserver` event) the passed in action is called with the element and the current x/y position
  - having this handled via the `{{movable}}` modifier is needed because that's what knows about any CSS translations that are present and allows the drag position to remain in sync with any resize-related adjustments
  - return value of the action is expected to be a new `{x, y}` tuple
- used `adjustOnResize` in the `<KoenigSettingsPanel>` component so that any time the settings panel grows and causes part of it to be off-screen we re-position so that the whole panel is kept on-screen
2021-11-12 18:45:03 +00:00

227 lines
7.1 KiB
JavaScript

import Modifier from 'ember-modifier';
import {action} from '@ember/object';
import {guidFor} from '@ember/object/internals';
import {inject as service} from '@ember/service';
export default class MovableModifier extends Modifier {
@service dropdown;
moveThreshold = 3;
active = false;
currentX = undefined;
currentY = undefined;
initialX = undefined;
initialY = undefined;
xOffset = 0;
yOffset = 0;
// Lifecycle hooks ---------------------------------------------------------
didInstall() {
this.addStartEventListeners();
if (this.args.named.adjustOnResize) {
this._resizeObserver = new ResizeObserver(() => {
if (this.currentX === undefined || this.currentY === undefined) {
return;
}
const {x, y} = this.args.named.adjustOnResize(this.element, {x: this.currentX, y: this.currentY});
if (x === this.currentX && y === this.currentY) {
return;
}
this.currentX = x;
this.initialX = x;
this.xOffset = x;
this.currentY = y;
this.initialY = y;
this.yOffset = y;
this.setTranslate(x, y);
});
this._resizeObserver.observe(this.element);
}
}
willDestroy() {
this.removeEventListeners();
this.removeResizeObserver();
this.enableSelection();
}
// Custom methods -----------------------------------------------------------
addStartEventListeners() {
this.element.addEventListener('touchstart', this.dragStart, false);
this.element.addEventListener('mousedown', this.dragStart, false);
}
removeStartEventListeners() {
this.element.removeEventListener('touchstart', this.dragStart, false);
this.element.removeEventListener('mousedown', this.dragStart, false);
}
addActiveEventListeners() {
window.addEventListener('touchend', this.dragEnd, {capture: true, passive: false});
window.addEventListener('touchmove', this.drag, {capture: true, passive: false});
window.addEventListener('mouseup', this.dragEnd, {capture: true, passive: false});
window.addEventListener('mousemove', this.drag, {capture: true, passive: false});
}
removeActiveEventListeners() {
window.removeEventListener('touchend', this.dragEnd, {capture: true, passive: false});
window.removeEventListener('touchmove', this.drag, {capture: true, passive: false});
window.removeEventListener('mouseup', this.dragEnd, {capture: true, passive: false});
window.removeEventListener('mousemove', this.drag, {capture: true, passive: false});
// Removing this immediately results in the click event behind re-enabled in the same
// event loop meaning that it doesn't have the desired effect when dragging out of the canvas.
// Putting in the next tick stops the immediate click event firing when finishing drag
setTimeout(() => {
window.removeEventListener('click', this.cancelClick, {capture: true, passive: false});
}, 1);
}
removeEventListeners() {
this.removeStartEventListeners();
this.removeActiveEventListeners();
}
removeResizeObserver() {
this._resizeObserver?.disconnect();
}
@action
dragStart(e) {
if (e.type === 'touchstart' || e.button === 0) {
if (e.type === 'touchstart') {
this.initialX = e.touches[0].clientX - this.xOffset;
this.initialY = e.touches[0].clientY - this.yOffset;
} else {
this.initialX = e.clientX - this.xOffset;
this.initialY = e.clientY - this.yOffset;
}
for (const elem of (e.path || e.composedPath())) {
if (elem.matches('input, .ember-basic-dropdown-trigger')) {
break;
}
if (elem === this.element) {
this.addActiveEventListeners();
break;
}
}
}
}
@action
drag(e) {
e.preventDefault();
let eventX, eventY;
if (e.type === 'touchmove') {
eventX = e.touches[0].clientX;
eventY = e.touches[0].clientY;
} else {
eventX = e.clientX;
eventY = e.clientY;
}
if (!this.active) {
if (
Math.abs(Math.abs(this.initialX - eventX) - Math.abs(this.xOffset)) > this.moveThreshold ||
Math.abs(Math.abs(this.initialY - eventY) - Math.abs(this.yOffset)) > this.moveThreshold
) {
this.dropdown.closeDropdowns();
this.disableScroll();
this.disableSelection();
this.disablePointerEvents();
this.active = true;
}
}
if (this.active) {
this.currentX = eventX - this.initialX;
this.currentY = eventY - this.initialY;
this.xOffset = this.currentX;
this.yOffset = this.currentY;
this.setTranslate(this.currentX, this.currentY);
}
}
@action
dragEnd(e) {
e.preventDefault();
e.stopPropagation();
this.active = false;
this.initialX = this.currentX;
this.initialY = this.currentY;
this.removeActiveEventListeners();
this.enableScroll();
this.enableSelection();
// timeout required so immediate events blocked until the dragEnd has fully realised
setTimeout(() => {
this.enablePointerEvents();
}, 5);
}
@action
cancelClick(e) {
e.preventDefault();
e.stopPropagation();
}
setTranslate(xPos, yPos) {
this.element.style.transform = `translate3d(${xPos}px, ${yPos}px, 0)`;
}
disableScroll() {
this.originalOverflow = this.element.style.overflow;
this.element.style.overflow = 'hidden';
}
enableScroll() {
this.element.style.overflow = this.originalOverflow;
}
disableSelection() {
window.getSelection().removeAllRanges();
const stylesheet = document.createElement('style');
stylesheet.id = `stylesheet-${guidFor(this)}`;
document.head.appendChild(stylesheet);
stylesheet.sheet.insertRule('* { user-select: none !important; }', 0);
}
enableSelection() {
const stylesheet = document.getElementById(`stylesheet-${guidFor(this)}`);
stylesheet?.remove();
}
// disabling pointer events prevents inputs being activated when drag finishes,
// preventing clicks stops any event handlers that may otherwise result in the
// movable element being closed when the drag finishes
disablePointerEvents() {
this.element.style.pointerEvents = 'none';
window.addEventListener('click', this.cancelClick, {capture: true, passive: false});
}
enablePointerEvents() {
this.element.style.pointerEvents = '';
window.removeEventListener('click', this.cancelClick, {capture: true, passive: false});
}
}