import React, { ReactInstance } from 'react';
import ReactDOM from 'react-dom';
import { InjectedIntlProps, injectIntl } from 'react-intl';
import { EmojiPicker as AkEmojiPicker } from '@atlaskit/emoji/picker';
import { EmojiId } from '@atlaskit/emoji/types';
import { Popup } from '@atlaskit/editor-common';
import ToolbarButton, { ToolbarButtonRef } from '../../../../ui/ToolbarButton';
import { Separator, ButtonGroup, Wrapper } from '../../../../ui/styles';
import { createTable } from '../../../table/commands';
import { insertDate, openDatePicker } from '../../../date/actions';
import { openElementBrowserModal } from '../../../quick-insert/commands';
import { showPlaceholderFloatingToolbar } from '../../../placeholder-text/actions';
import { createHorizontalRule } from '../../../rule/pm-plugins/input-rule';
import { insertLayoutColumnsWithAnalytics } from '../../../layout/actions';
import { insertTaskDecision } from '../../../tasks-and-decisions/commands';
import { insertExpand } from '../../../expand/commands';
import { showLinkToolbar } from '../../../hyperlink/commands';
import { insertMentionQuery } from '../../../mentions/commands/insert-mention-query';
import { updateStatusWithAnalytics } from '../../../status/actions';
import {
ACTION,
ACTION_SUBJECT,
ACTION_SUBJECT_ID,
EVENT_TYPE,
INPUT_METHOD,
withAnalytics as commandWithAnalytics,
} from '../../../analytics';
import { insertEmoji } from '../../../emoji/commands/insert-emoji';
import { DropdownItem } from '../../../block-type/ui/ToolbarBlockType';
import { OnInsert } from '../../../../ui/ElementBrowser/types';
import { messages } from './messages';
import { Props, State, TOOLBAR_MENU_TYPE } from './types';
import { createItems } from './create-items';
import { BlockInsertMenu } from './block-insert-menu';
/**
* Checks if an element is detached (i.e. not in the current document)
*/
const isDetachedElement = (el: HTMLElement) => !document.body.contains(el);
const noop = () => {};
class ToolbarInsertBlock extends React.PureComponent<
Props & InjectedIntlProps,
State
> {
private dropdownButtonRef?: HTMLElement;
private pickerRef?: ReactInstance;
private emojiButtonRef?: HTMLElement;
private plusButtonRef?: HTMLElement;
state: State = {
isPlusMenuOpen: false,
emojiPickerOpen: false,
buttons: [],
dropdownItems: [],
};
static getDerivedStateFromProps(
props: Props & InjectedIntlProps,
state: State,
): State | null {
const [buttons, dropdownItems] = createItems({
isTypeAheadAllowed: props.isTypeAheadAllowed,
tableSupported: props.tableSupported,
mediaUploadsEnabled: props.mediaUploadsEnabled,
mediaSupported: props.mediaSupported,
imageUploadSupported: props.imageUploadSupported,
imageUploadEnabled: props.imageUploadEnabled,
mentionsSupported: props.mentionsSupported,
actionSupported: props.actionSupported,
decisionSupported: props.decisionSupported,
linkSupported: props.linkSupported,
linkDisabled: props.linkDisabled,
emojiDisabled: props.emojiDisabled,
nativeStatusSupported: props.nativeStatusSupported,
dateEnabled: props.dateEnabled,
placeholderTextEnabled: props.placeholderTextEnabled,
horizontalRuleEnabled: props.horizontalRuleEnabled,
layoutSectionEnabled: props.layoutSectionEnabled,
expandEnabled: props.expandEnabled,
macroProvider: props.macroProvider,
showElementBrowserLink: props.showElementBrowserLink,
emojiProvider: props.emojiProvider,
availableWrapperBlockTypes: props.availableWrapperBlockTypes,
insertMenuItems: props.insertMenuItems,
schema: props.editorView.state.schema,
numberOfButtons: props.buttons,
formatMessage: props.intl.formatMessage,
isNewMenuEnabled: props.replacePlusMenuWithElementBrowser,
});
return {
...state,
buttons,
dropdownItems,
};
}
componentDidUpdate(prevProps: Props) {
// If number of visible buttons changed, close emoji picker
if (prevProps.buttons !== this.props.buttons) {
this.setState({ emojiPickerOpen: false });
}
}
private onOpenChange = (attrs: {
isPlusMenuOpen: boolean;
open?: boolean;
}) => {
const state = {
isPlusMenuOpen: attrs.isPlusMenuOpen,
emojiPickerOpen: this.state.emojiPickerOpen,
};
if (this.state.emojiPickerOpen && !attrs.open) {
state.emojiPickerOpen = false;
}
this.setState(state, () => {
const { dispatchAnalyticsEvent } = this.props;
if (!dispatchAnalyticsEvent) {
return;
}
const { isPlusMenuOpen } = this.state;
if (isPlusMenuOpen) {
return dispatchAnalyticsEvent({
action: ACTION.OPENED,
actionSubject: ACTION_SUBJECT.PLUS_MENU as any,
eventType: EVENT_TYPE.UI,
});
}
return dispatchAnalyticsEvent({
action: ACTION.CLOSED,
actionSubject: ACTION_SUBJECT.PLUS_MENU as any,
eventType: EVENT_TYPE.UI,
});
});
};
private togglePlusMenuVisibility = () => {
const { isPlusMenuOpen } = this.state;
this.onOpenChange({ isPlusMenuOpen: !isPlusMenuOpen });
};
private toggleEmojiPicker = (
inputMethod: TOOLBAR_MENU_TYPE = INPUT_METHOD.TOOLBAR,
) => {
this.setState(
(prevState) => ({ emojiPickerOpen: !prevState.emojiPickerOpen }),
() => {
if (this.state.emojiPickerOpen) {
const { dispatchAnalyticsEvent } = this.props;
if (dispatchAnalyticsEvent) {
dispatchAnalyticsEvent({
action: ACTION.OPENED,
actionSubject: ACTION_SUBJECT.PICKER,
actionSubjectId: ACTION_SUBJECT_ID.PICKER_EMOJI,
attributes: { inputMethod },
eventType: EVENT_TYPE.UI,
});
}
}
},
);
};
private renderPopup() {
const { emojiPickerOpen } = this.state;
const {
popupsMountPoint,
popupsBoundariesElement,
popupsScrollableElement,
emojiProvider,
replacePlusMenuWithElementBrowser,
} = this.props;
const dropdownEmoji = this.state.dropdownItems.some(
({ value: { name } }) => name === 'emoji',
);
const dropDownButtonRef = replacePlusMenuWithElementBrowser
? this.plusButtonRef
: this.dropdownButtonRef;
const ref = dropdownEmoji ? dropDownButtonRef : this.emojiButtonRef;
if (!emojiPickerOpen || !ref || !emojiProvider) {
return null;
}
return (
);
}
private handleEmojiButtonRef = (button: ToolbarButtonRef): void => {
const ref = ReactDOM.findDOMNode(button) as HTMLElement | null;
if (ref) {
this.emojiButtonRef = ref;
}
};
private handlePlusButtonRef = (button: ToolbarButtonRef): void => {
const ref = ReactDOM.findDOMNode(button) as HTMLElement | null;
if (ref) {
this.plusButtonRef = ref;
}
};
private handleDropDownButtonRef = (button: ToolbarButtonRef) => {
const ref = ReactDOM.findDOMNode(button) as HTMLElement | null;
if (ref) {
this.dropdownButtonRef = ref;
}
};
private onPickerRef = (ref: any) => {
if (ref) {
document.addEventListener('click', this.handleClickOutside);
} else {
document.removeEventListener('click', this.handleClickOutside);
}
this.pickerRef = ref;
};
private handleClickOutside = (e: MouseEvent) => {
const picker = this.pickerRef && ReactDOM.findDOMNode(this.pickerRef);
// Ignore click events for detached elements.
// Workaround for FS-1322 - where two onClicks fire - one when the upload button is
// still in the document, and one once it's detached. Does not always occur, and
// may be a side effect of a react render optimisation
if (
!picker ||
(e.target &&
!isDetachedElement(e.target as HTMLElement) &&
!picker.contains(e.target as HTMLElement))
) {
this.toggleEmojiPicker();
}
};
render() {
const { buttons, dropdownItems } = this.state;
const { isDisabled, isReducedSpacing } = this.props;
if (buttons.length === 0 && dropdownItems.length === 0) {
return null;
}
return (
{buttons.map((btn) => (
))}
{this.renderPopup()}
{this.props.showSeparator && }
);
}
private toggleLinkPanel = (inputMethod: TOOLBAR_MENU_TYPE): boolean => {
const { editorView } = this.props;
showLinkToolbar(inputMethod)(editorView.state, editorView.dispatch);
return true;
};
private insertMention = (inputMethod: TOOLBAR_MENU_TYPE): boolean => {
const { editorView } = this.props;
insertMentionQuery(inputMethod)(editorView.state, editorView.dispatch);
return true;
};
private insertTable = (inputMethod: TOOLBAR_MENU_TYPE): boolean => {
const { editorView, allowLocalIdGenerationOnTables } = this.props;
return commandWithAnalytics({
action: ACTION.INSERTED,
actionSubject: ACTION_SUBJECT.DOCUMENT,
actionSubjectId: ACTION_SUBJECT_ID.TABLE,
attributes: { inputMethod },
eventType: EVENT_TYPE.TRACK,
})(createTable(allowLocalIdGenerationOnTables))(
editorView.state,
editorView.dispatch,
);
};
private createDate = (inputMethod: TOOLBAR_MENU_TYPE): boolean => {
const { editorView } = this.props;
insertDate(undefined, inputMethod)(editorView.state, editorView.dispatch);
openDatePicker()(editorView.state, editorView.dispatch);
return true;
};
private createPlaceholderText = (): boolean => {
const { editorView } = this.props;
showPlaceholderFloatingToolbar(editorView.state, editorView.dispatch);
return true;
};
private insertLayoutColumns = (inputMethod: TOOLBAR_MENU_TYPE): boolean => {
const { editorView } = this.props;
insertLayoutColumnsWithAnalytics(inputMethod)(
editorView.state,
editorView.dispatch,
);
return true;
};
private createStatus = (inputMethod: TOOLBAR_MENU_TYPE): boolean => {
const { editorView } = this.props;
updateStatusWithAnalytics(inputMethod)(
editorView.state,
editorView.dispatch,
);
return true;
};
private openMediaPicker = (inputMethod: TOOLBAR_MENU_TYPE): boolean => {
const { onShowMediaPicker, dispatchAnalyticsEvent } = this.props;
if (onShowMediaPicker) {
onShowMediaPicker();
if (dispatchAnalyticsEvent) {
dispatchAnalyticsEvent({
action: ACTION.OPENED,
actionSubject: ACTION_SUBJECT.PICKER,
actionSubjectId: ACTION_SUBJECT_ID.PICKER_CLOUD,
attributes: { inputMethod },
eventType: EVENT_TYPE.UI,
});
}
}
return true;
};
private insertTaskDecision = (
name: 'action' | 'decision',
inputMethod: TOOLBAR_MENU_TYPE,
) => (): boolean => {
const { editorView } = this.props;
if (!editorView) {
return false;
}
const listType = name === 'action' ? 'taskList' : 'decisionList';
insertTaskDecision(
editorView,
listType,
inputMethod,
)(editorView.state, editorView.dispatch);
return true;
};
private insertHorizontalRule = (inputMethod: TOOLBAR_MENU_TYPE): boolean => {
const { editorView } = this.props;
const tr = createHorizontalRule(
editorView.state,
editorView.state.selection.from,
editorView.state.selection.to,
inputMethod,
);
if (tr) {
editorView.dispatch(tr);
return true;
}
return false;
};
private insertExpand = (): boolean => {
const { state, dispatch } = this.props.editorView;
return insertExpand(state, dispatch);
};
private insertBlockType = (itemName: string) => () => {
const { editorView, onInsertBlockType } = this.props;
const { state, dispatch } = editorView;
onInsertBlockType!(itemName)(state, dispatch);
return true;
};
private handleSelectedEmoji = (emojiId: EmojiId): boolean => {
this.props.editorView.focus();
insertEmoji(emojiId, INPUT_METHOD.PICKER)(
this.props.editorView.state,
this.props.editorView.dispatch,
);
this.toggleEmojiPicker();
return true;
};
private openElementBrowser = () => {
openElementBrowserModal()(
this.props.editorView.state,
this.props.editorView.dispatch,
);
};
private onItemActivated = ({
item,
inputMethod,
}: {
item: any;
inputMethod: TOOLBAR_MENU_TYPE;
}): void => {
const {
editorView,
editorActions,
handleImageUpload,
expandEnabled,
} = this.props;
// need to do this before inserting nodes so scrollIntoView works properly
if (!editorView.hasFocus()) {
editorView.focus();
}
switch (item.value.name) {
case 'link':
this.toggleLinkPanel(inputMethod);
break;
case 'table':
this.insertTable(inputMethod);
break;
case 'image upload':
if (handleImageUpload) {
const { state, dispatch } = editorView;
handleImageUpload()(state, dispatch);
}
break;
case 'media':
this.openMediaPicker(inputMethod);
break;
case 'mention':
this.insertMention(inputMethod);
break;
case 'emoji':
this.toggleEmojiPicker(inputMethod);
break;
case 'codeblock':
case 'blockquote':
case 'panel':
this.insertBlockType(item.value.name)();
break;
case 'action':
case 'decision':
this.insertTaskDecision(item.value.name, inputMethod)();
break;
case 'horizontalrule':
this.insertHorizontalRule(inputMethod);
break;
case 'macro':
this.openElementBrowser();
break;
case 'date':
this.createDate(inputMethod);
break;
case 'placeholder text':
this.createPlaceholderText();
break;
case 'layout':
this.insertLayoutColumns(inputMethod);
break;
case 'status':
this.createStatus(inputMethod);
break;
// https://product-fabric.atlassian.net/browse/ED-8053
// @ts-ignore: OK to fallthrough to default
case 'expand':
if (expandEnabled) {
this.insertExpand();
break;
}
// eslint-disable-next-line no-fallthrough
default:
if (item && item.onClick) {
item.onClick(editorActions);
break;
}
}
this.setState({ isPlusMenuOpen: false });
};
private insertToolbarMenuItem = (btn: any) =>
this.onItemActivated({
item: btn,
inputMethod: INPUT_METHOD.TOOLBAR,
});
private insertInsertMenuItem = ({ item }: { item: DropdownItem }) =>
this.onItemActivated({
item,
inputMethod: INPUT_METHOD.INSERT_MENU,
});
}
export default injectIntl(ToolbarInsertBlock);