Ghost/ghost/admin/lib/koenig-editor/addon/components/koenig-card-markdown.js
Kevin Ansfield ef71d52ec0 Fixed Cannot read properties of null (reading 'offsetHeight') errors in markdown card
closes sentry ADMIN-CAV

- the mobile nav element is not always displayed so we should assume we can read it's `offsetHeight` property
2022-11-24 12:52:16 +00:00

242 lines
6.7 KiB
JavaScript

import Component from '@ember/component';
import classic from 'ember-classic-decorator';
import formatMarkdown from 'ghost-admin/utils/format-markdown';
import {action, computed, set} from '@ember/object';
import {utils as ghostHelperUtils} from '@tryghost/helpers';
import {htmlSafe} from '@ember/template';
import {isBlank} from '@ember/utils';
import {run} from '@ember/runloop';
import {task, timeout} from 'ember-concurrency';
const {countWords, countImages} = ghostHelperUtils;
const MIN_HEIGHT = 130;
@classic
export default class KoenigCardMarkdown extends Component {
// attrs
payload = null;
isSelected = false;
isEditing = false;
headerOffset = 0;
// internal attrs
bottomOffset = 0;
preventClick = false;
// closure actions
editCard() {}
saveCard() {}
selectCard() {}
deselectCard() {}
deleteCard() {}
registerComponent() {}
@computed('payload.markdown')
get isEmpty() {
return isBlank(this.payload.markdown);
}
@computed('renderedMarkdown')
get counts() {
return {
wordCount: countWords(this.renderedMarkdown),
imageCount: countImages(this.renderedMarkdown)
};
}
@computed('payload.markdown')
get renderedMarkdown() {
return htmlSafe(formatMarkdown(this.payload.markdown));
}
@computed('isEditing')
get toolbar() {
if (this.isEditing) {
return false;
}
return {
items: [{
buttonClass: 'fw4 flex items-center white',
icon: 'koenig/kg-edit',
iconClass: 'fill-white',
title: 'Edit',
text: '',
action: run.bind(this, this.editCard)
}]
};
}
init() {
super.init(...arguments);
if (!this.payload) {
this.set('payload', {});
}
// subtract toolbar height from MIN_HEIGHT so the trigger happens at
// the expected position without forcing the min height to be too small
this.set('bottomOffset', -MIN_HEIGHT);
this.registerComponent(this);
}
willDestroyElement() {
super.willDestroyElement(...arguments);
this._teardownResizeHandler();
}
@action
enterEditMode() {
this._preventAccidentalClick.perform();
}
@action
leaveEditMode() {
if (this.isEmpty) {
// afterRender is required to avoid double modification of `isSelected`
// TODO: see if there's a way to avoid afterRender
run.scheduleOnce('afterRender', this, this.deleteCard);
}
}
@action
updateMarkdown(markdown) {
let payload = this.payload;
let save = this.saveCard;
set(payload, 'markdown', markdown);
// update the mobiledoc and stay in edit mode
save(payload, false);
}
// fires if top comes into view 0 px from viewport top
// fires if top comes into view MIN_HEIGHTpx above viewport bottom
@action
topEntered() {
this._isTopVisible = true;
run.scheduleOnce('actions', this, this._applyToolbarStyles);
}
// fires if top leaves viewport 0 px from viewport top
// fires if top leaves viewport MIN_HEIGHTpx above viewport bottom
@action
topExited() {
let top = this._topElement.getBoundingClientRect().top;
this._isTopVisible = false;
this._isTopAbove = top < 0;
run.scheduleOnce('actions', this, this._applyToolbarStyles);
}
@action
bottomEntered() {
this._isBottomVisible = true;
run.scheduleOnce('actions', this, this._applyToolbarStyles);
}
@action
bottomExited() {
let top = this._bottomElement.getBoundingClientRect().top;
this._isBottomVisible = false;
this._isBottomBelow = top > window.innerHeight;
run.scheduleOnce('actions', this, this._applyToolbarStyles);
}
@action
registerTop(element) {
this._topElement = element;
}
@action
registerBottom(element) {
this._bottomElement = element;
}
_applyToolbarStyles() {
if (this.isDestroyed || this.isDestroying) {
return;
}
let toolbar = this.element?.querySelector('.editor-toolbar');
if (!toolbar) {
return;
}
let {left, width} = this._containerDimensions();
let style = '';
let stuckTop = `top: ${MIN_HEIGHT}px; bottom: auto`;
let fixedBottom = `position: fixed; left: ${left + 1}px; width: ${width - 2}px`;
let stuckBottom = '';
if (this._isTopVisible && this._isBottomVisible) {
style = stuckBottom;
}
if (this._isTopVisible && !this._isBottomVisible) {
style = fixedBottom;
}
if (!this._isTopVisible && !this._isTopAbove) {
style = stuckTop;
}
if (!this._isTopVisible && this._isBottomVisible) {
style = stuckBottom;
}
if (!this._isTopVisible && !this._isBottomVisible && this._isTopAbove && this._isBottomBelow) {
style = fixedBottom;
}
// set up resize watchers if in fixed position because we have to
// recalculate left position and width
if (!this._resizeHandler && style === fixedBottom) {
this._setupResizeHandler();
} else if (this._resizeHandler && style !== fixedBottom) {
this._teardownResizeHandler();
}
// account for the mobile nav bar when in fixed position
if (style === fixedBottom) {
let mobileNav = document.querySelector('.gh-mobile-nav-bar');
if (mobileNav?.offsetHeight) {
style = `${style}; bottom: ${mobileNav.offsetHeight}px`;
}
}
toolbar.setAttribute('style', style);
}
_containerDimensions() {
return this.element.querySelector('.kg-card-selected').getBoundingClientRect();
}
_setupResizeHandler() {
if (this._resizeHandler) {
return;
}
this._resizeHandler = run.bind(this, this._applyToolbarStyles);
window.addEventListener('resize', this._resizeHandler);
}
_teardownResizeHandler() {
window.removeEventListener('resize', this._resizeHandler);
this._resizeHandler = null;
}
// when entering edit mode it can be easy to accidentally click where the
// toolbar is inserted. Setting `preventClick` to true adds an overlay, so
// we set that for half a second to stop double-clicks hitting the toolbar
@task(function* () {
this.set('preventClick', true);
yield timeout(500);
this.set('preventClick', false);
})
_preventAccidentalClick;
}