box-ui-elements
Version:
Box UI Elements
557 lines (484 loc) • 23.4 kB
JavaScript
// @flow
import * as React from 'react';
import { CompositeDecorator, EditorState, Modifier, SelectionState, ContentState } from 'draft-js';
import noop from 'lodash/noop';
import DraftJSMentionSelectorCore from './DraftJSMentionSelectorCore';
import DraftMentionItem from './DraftMentionItem';
import DraftTimestampItem from './DraftTimestampItem';
import FormInput from '../form/FormInput';
import * as messages from '../input-messages';
import type { SelectorItems } from '../../../common/types/core';
import Toggle from '../../toggle/Toggle';
import { UNEDITABLE_TIMESTAMP_TEXT } from './utils';
import { convertSecondsToHMMSS } from '../../../utils/timestamp';
type videoTimestamp = {
timestamp: string,
timestampInMilliseconds: number,
};
/**
* Scans a Draft ContentBlock for entity ranges, so they can be annotated
* @see docs at {@link https://draftjs.org/docs/advanced-topics-decorators.html#compositedecorator}
* @param {ContentBlock} contentBlock
* @param {function} callback
* @param {ContentState} contentState
*/
const mentionStrategy = (contentBlock, callback, contentState) => {
contentBlock.findEntityRanges(character => {
const entityKey = character.getEntity();
const ret = entityKey !== null && contentState.getEntity(entityKey).getType() === 'MENTION';
return ret;
}, callback);
};
/**
* Scans a Draft ContentBlock for timestamp entity ranges
* @see docs at {@link https://draftjs.org/docs/advanced-topics-decorators.html#compositedecorator}
* @param {ContentBlock} contentBlock
* @param {function} callback
* @param {ContentState} contentState
*/
const timestampStrategy = (contentBlock: any, callback: (start: number, end: number) => void, contentState: any) => {
if (!contentBlock || !contentState) {
return;
}
contentBlock.findEntityRanges(character => {
const entityKey = character.getEntity();
const hasEntityKey = entityKey !== null;
// $FlowFixMe
const entityType = hasEntityKey && contentState?.getEntity(entityKey)?.getType();
const timeStampEntityFound = entityType === UNEDITABLE_TIMESTAMP_TEXT;
return timeStampEntityFound;
}, callback);
};
type Props = {
className?: string,
contacts: SelectorItems<>,
contactsLoaded?: boolean,
description?: React.Node,
editorState?: EditorState,
fileVersionId?: string,
hideLabel?: boolean,
isDisabled?: boolean,
isRequired?: boolean,
label: React.Node,
maxLength?: number,
mentionTriggers?: Array<string>,
minLength?: number,
name: string,
onChange: Function,
onFocus?: Function,
onMention?: Function,
onReturn?: Function,
placeholder?: string,
selectorRow?: React.Element<any>,
startMentionMessage?: React.Node,
timestampLabel?: string | null,
validateOnBlur?: boolean,
};
type State = {
contacts: SelectorItems<>,
error: ?Object,
internalEditorState: ?EditorState,
isTouched: boolean,
isTimestampToggledOn: boolean,
};
class DraftJSMentionSelector extends React.Component<Props, State> {
compositeDecorator: CompositeDecorator;
static defaultProps = {
isRequired: false,
onChange: noop,
validateOnBlur: true,
};
constructor(props: Props) {
super(props);
this.compositeDecorator = new CompositeDecorator([
{
strategy: mentionStrategy,
component: DraftMentionItem,
},
{
strategy: timestampStrategy,
component: DraftTimestampItem,
},
]);
// @NOTE:
// This component might be either own its EditorState (in which case it lives in `this.state.internalEditorState`)
// or be a controlled component whose EditorState is passed in via the `editorState` prop.
// If `props.editorState` is set, `internalEditorState` is `null`,
// otherwise we initialize it here
this.state = {
contacts: [],
isTouched: false,
internalEditorState: props.editorState ? null : EditorState.createEmpty(this.compositeDecorator),
error: null,
isTimestampToggledOn: false,
};
}
static getDerivedStateFromProps(nextProps: Props) {
const { contacts } = nextProps;
return contacts ? { contacts } : null;
}
componentDidMount() {
// if video timestamping is enabled we need to check if a timestamp entity is present in the editor state passed in via props
// and if it is then set the isTimestampToggledOn state to true. This will happen when the user is editing a comment
// that has a timestamp entity.
if (this.getIsVideoTimestampEnabled()) {
const { isTimestampToggledOn, internalEditorState } = this.state;
const { editorState: externalEditorState } = this.props;
const currentEditorState = internalEditorState || externalEditorState;
// if video timestamping is enabled and the editor state is being passed in check if a timestamp entity is present
// and if it is then set the isTimestampToggledOn state to true.
if (!isTimestampToggledOn && currentEditorState) {
const currentContent = currentEditorState.getCurrentContent();
const isTimeStampEntityPresent = this.getIsTimestampEntityPresent(currentContent);
if (isTimeStampEntityPresent) {
this.setState({ isTimestampToggledOn: true });
}
}
}
}
componentDidUpdate(prevProps: Props, prevState: State) {
const { internalEditorState: prevInternalEditorState } = prevState;
const { internalEditorState } = this.state;
const { editorState: prevEditorStateFromProps, isRequired: prevIsRequiredFromProps } = prevProps;
const { editorState, isRequired } = this.props;
// Determine whether we're working with the internal editor state or
// external editor state passed in from props
const prevEditorState = prevInternalEditorState || prevEditorStateFromProps;
const currentEditorState = internalEditorState || editorState;
// Only handle isTouched state transitions and check validity if the
// editorState references are different. This is to avoid getting stuck
// in an infinite loop of checking validity because checkValidity always
// calls setState({ error })
if (prevEditorState && currentEditorState && prevEditorState !== currentEditorState) {
const newState = this.getDerivedStateFromEditorState(currentEditorState, prevEditorState);
if (newState) {
this.setState(newState, this.checkValidityIfAllowed);
} else {
this.checkValidityIfAllowed();
}
}
// if isRequired is false then the comment box will be closed and we want
// to make sure that isTimestampToggledOn is always set to false in this casee
if (this.getIsVideoTimestampEnabled() && isRequired !== prevIsRequiredFromProps && isRequired === false) {
this.setState({ isTimestampToggledOn: false });
}
// If timestamplabel is set and isRequired is true then force the timestamp
// to be added to the editor state as that is the specified default behavior for video comments
if (this.getIsVideoTimestampEnabled() && isRequired !== prevIsRequiredFromProps && isRequired === true) {
this.toggleTimestamp(currentEditorState, true);
}
}
getIsVideoTimestampEnabled = () => {
const { timestampLabel } = this.props;
return !!timestampLabel && timestampLabel.trim() !== '';
};
getDerivedStateFromEditorState(currentEditorState: EditorState, previousEditorState: EditorState) {
const isPreviousEditorStateEmpty = this.isEditorStateEmpty(previousEditorState);
const isCurrentEditorStateEmpty = this.isEditorStateEmpty(currentEditorState);
const isNewEditorState = isCurrentEditorStateEmpty && !isPreviousEditorStateEmpty;
const isEditorStateDirty = isPreviousEditorStateEmpty && !isCurrentEditorStateEmpty;
let newState = null;
// Detect case where controlled EditorState is created anew and empty.
// If next editorState is empty and the current editorState is not empty
// that means it is a new empty state and this component should not be marked dirty
if (isNewEditorState) {
newState = { isTouched: false, error: null };
} else if (isEditorStateDirty) {
// Detect case where controlled EditorState has been made dirty
// If the current editorState is empty and the next editorState is not
// empty then this is the first interaction so mark this component dirty
newState = { isTouched: true };
}
return newState;
}
toggleTimestamp = (editorState: ?EditorState, forceOn: boolean = false) => {
if (!editorState) return;
const currentContent = editorState.getCurrentContent();
let updatedContent;
let newIsTimestampToggledOn;
const { isTimestampToggledOn } = this.state;
// If timestamp is already prepended and forceOn is true, do not toggle it.
if (isTimestampToggledOn && forceOn) {
return;
}
const timestampLengthIncludingSpace = this.getTimestampLength(currentContent);
const isTimestampEntityPresent = timestampLengthIncludingSpace > 0;
// check if we need to toggle the timestamp on and that the timestamp entity is not already present in the content
if ((!isTimestampToggledOn || forceOn) && !isTimestampEntityPresent) {
// get the current timestamp
const { timestamp, timestampInMilliseconds } = this.getVideoTimestamp();
const { fileVersionId } = this.props;
const timestampText = `${timestamp}`;
// Create a new entity for the timestamp. It is immutable so it will not be editable. Adding
// timestampInMilliseconds, and fileVersionId to the entity data which will be used when the comment form is submitted
// and will be added to the text of the comment. This will let us filter out timetsamped comments based on version and also
// be able to click the timestamp button in comments in the sidebar and got to the proper place in the video.
// $FlowFixMe
const contentWithTimestampEntity = currentContent.createEntity(
UNEDITABLE_TIMESTAMP_TEXT, // Entity type
'IMMUTABLE',
{ timestampInMilliseconds, fileVersionId },
);
// Create a selection at the very beginning of the input box for the timestamp
const selectionAtStart = SelectionState.createEmpty(
contentWithTimestampEntity.getFirstBlock().getKey(),
).merge({
anchorOffset: 0,
focusOffset: 0,
});
// First insert the timestamp text followed by a space
updatedContent = Modifier.insertText(contentWithTimestampEntity, selectionAtStart, `${timestampText} `);
// Then select the timestamp text not including the space
const selectionWithTimestamp = SelectionState.createEmpty(updatedContent.getFirstBlock().getKey()).merge({
anchorOffset: 0,
focusOffset: timestampText.length,
});
// Get the entity key for the timestamp entity
const entityKey = contentWithTimestampEntity.getLastCreatedEntityKey();
// Apply the timestamp entity to selected timestamp text. This will ensure that the timestamp is uneditable and that
// the decorator will apply the proper styling to the timestamp.
updatedContent = Modifier.applyEntity(updatedContent, selectionWithTimestamp, entityKey);
newIsTimestampToggledOn = true;
} else {
// Create a selection range for the timestamp text and space so that we know what to remove and
// remove it from the beginning of the input box. This uses the timestsamp length that we calculated earlier.
const selectionToRemove = SelectionState.createEmpty(currentContent.getFirstBlock().getKey()).merge({
anchorOffset: 0,
focusOffset: timestampLengthIncludingSpace,
});
// Remove the timestamp text and space. No need for an entity key because we are not applying any entity to the text.
updatedContent = Modifier.replaceText(currentContent, selectionToRemove, '');
newIsTimestampToggledOn = false;
}
// Position cursor after the timestamp and space (if adding) or at the beginning (if removing)
const cursorOffset = newIsTimestampToggledOn ? timestampLengthIncludingSpace : 0;
// Create a selection that ensures the cursor is outside any entity. This is important because we want to ensure
// that the cursor is not inside the timestamp component when it is displayed
const finalSelection = SelectionState.createEmpty(updatedContent.getFirstBlock().getKey()).merge({
anchorOffset: cursorOffset,
focusOffset: cursorOffset,
});
// Create a new EditorState with the updated content
let newEditorState = EditorState.push(editorState, updatedContent, 'insert-characters');
// Apply selection first
newEditorState = EditorState.forceSelection(newEditorState, finalSelection);
// Update state with new timestamp status
this.setState({
isTimestampToggledOn: newIsTimestampToggledOn,
});
// handle the change in the editor state
this.handleChange(newEditorState);
};
checkValidityIfAllowed() {
const { validateOnBlur }: Props = this.props;
if (!validateOnBlur) {
this.checkValidity();
}
}
isEditorStateEmpty(editorState: EditorState): boolean {
const text = editorState.getCurrentContent().getPlainText().trim();
const lastChangeType = editorState.getLastChangeType();
return text.length === 0 && lastChangeType === null;
}
/**
* @returns {string}
*/
getErrorFromValidityState() {
const { editorState: externalEditorState, isRequired, maxLength, minLength } = this.props;
const { internalEditorState } = this.state;
// manually check for content length if isRequired is true
const editorState: EditorState = internalEditorState || externalEditorState;
const { length } = editorState.getCurrentContent().getPlainText().trim();
if (isRequired && !length) {
return messages.valueMissing();
}
if (typeof minLength !== 'undefined' && length < minLength) {
return messages.tooShort(minLength);
}
if (typeof maxLength !== 'undefined' && length > maxLength) {
return messages.tooLong(maxLength);
}
return null;
}
containerEl: ?HTMLDivElement;
/**
* Event handler called on blur. Triggers validation
* @param {SyntheticFocusEvent} event The event object
* @returns {void}
*/
handleBlur = (event: SyntheticFocusEvent<>) => {
if (
this.props.validateOnBlur &&
this.containerEl &&
event.relatedTarget instanceof Node &&
!this.containerEl.contains(event.relatedTarget)
) {
this.checkValidity();
}
};
handleFocus = (event: SyntheticEvent<>) => {
const { onFocus } = this.props;
if (onFocus) {
onFocus(event);
}
};
getIsTimestampEntityPresent = (currentContent: ContentState): boolean => {
return this.getTimestampLength(currentContent) > 0;
};
/**
* Calculates the length of the timestamp entity in the current block
* @param {ContentState} currentContent The current content state
* @param {ContentBlock} block The content block to analyze
* @returns {number} The length of the timestamp entity (including the space after it)
*/
getTimestampLength = (currentContent: ContentState): number => {
// $FlowFixMe
const block = currentContent?.getFirstBlock();
if (!currentContent || !block) {
return 0;
}
let timestampLength = 0;
const characterList = block.getCharacterList();
// get the length of the timestamp entity. This will include the space after the timestamp.
for (let i = 0; i < characterList.size; i += 1) {
const char = characterList.get(i);
if (char && char.getEntity()) {
const entity = currentContent.getEntity(char.getEntity());
if (entity.getType() === UNEDITABLE_TIMESTAMP_TEXT) {
timestampLength = i + 1;
}
}
}
// Include the space after the timestamp
return timestampLength ? timestampLength + 1 : 0;
};
/**
* Updates editorState, rechecks validity
* @param {EditorState} nextEditorState The new editor state to set in the state
* @returns {void}
*/
handleChange = (nextEditorState: EditorState) => {
const { internalEditorState, isTimestampToggledOn }: State = this.state;
const { onChange }: Props = this.props;
// Check if timestamp entity is still present in the content if video timestamping is enabled.
// Update the timestamp prepended state to false if the timestamp entity is no longer present in the editor content
// This can happen when the user deletes it with the backspace key.
if (this.getIsVideoTimestampEnabled() && isTimestampToggledOn) {
const currentContent = nextEditorState.getCurrentContent();
const firstBlock = currentContent.getFirstBlock();
const timestampLength = this.getTimestampLength(currentContent);
const timestampEntityFound = timestampLength > 0;
// If timestamp entity is no longer present, update the state
if (!timestampEntityFound) {
this.setState({ isTimestampToggledOn: false });
} else {
// Check if the timestamp entity is at the beginning of the content, if not do not update the editor state.
// This is to prevent the user from inserting text before the timestamp entity.
const characterList = firstBlock.getCharacterList();
const firstChar = characterList.get(0);
if (firstChar && !firstChar.getEntity()) {
return;
}
}
}
onChange(nextEditorState);
if (internalEditorState) {
const newState = { internalEditorState: nextEditorState };
this.setState(newState);
}
};
handleValidityStateUpdateHandler = () => {
const { isTouched } = this.state;
if (!isTouched) {
return;
}
const error = this.getErrorFromValidityState();
this.setState({ error });
};
checkValidity = () => {
this.handleValidityStateUpdateHandler();
};
getVideoTimestamp = (): videoTimestamp => {
const videoContainer: ?HTMLElement = document.querySelector('.bp-media-container');
// $FlowFixMe
const video: ?HTMLVideoElement = videoContainer?.querySelector('video');
const currentTime = video?.currentTime || 0;
// We need to get the nubmer of seconds in HMMSS format to display in the timestamp button
// and the timestamp in milliseconds to use when the comment form is submitted. This is because
// milliseconds are more precise than seconds and we need to make sure that we go to the right frame
// when the comment timestamp is clicked in the sidebar.
const totalSeconds = Math.floor(currentTime);
const timestampToDisplay = convertSecondsToHMMSS(totalSeconds);
const timestampInMilliseconds = Math.floor(currentTime * 1000);
return { timestamp: timestampToDisplay, timestampInMilliseconds };
};
render() {
const {
className = '',
contactsLoaded,
editorState: externalEditorState,
hideLabel,
isDisabled,
isRequired,
label,
description,
mentionTriggers,
name,
onMention,
placeholder,
selectorRow,
startMentionMessage,
onReturn,
timestampLabel,
} = this.props;
const { contacts, internalEditorState, error, isTimestampToggledOn: timestampToggledOn } = this.state;
const { handleBlur, handleChange, handleFocus, toggleTimestamp } = this;
let editorState: EditorState = internalEditorState || externalEditorState;
// Ensure the editor state has the composite decorator
if (editorState.getDecorator() !== this.compositeDecorator) {
editorState = EditorState.set(editorState, { decorator: this.compositeDecorator });
}
return (
<div
ref={containerEl => {
this.containerEl = containerEl;
}}
className={className}
>
<FormInput name={name} onValidityStateUpdate={this.handleValidityStateUpdateHandler}>
<DraftJSMentionSelectorCore
contacts={contacts}
contactsLoaded={contactsLoaded}
editorState={editorState}
error={error}
hideLabel={hideLabel}
isDisabled={isDisabled}
isRequired={isRequired}
label={label}
description={description}
mentionTriggers={mentionTriggers}
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
onMention={onMention}
onReturn={onReturn}
placeholder={placeholder}
selectorRow={selectorRow}
startMentionMessage={startMentionMessage}
/>
{isRequired && this.getIsVideoTimestampEnabled() && (
<Toggle
className="bcs-CommentTimestamp-toggle"
data-target-id="Toggle-CommentTimestamp"
// $FlowFixMe - timestampLabel is guaranteed to be defined when getIsVideoTimestampEnabled() returns true
label={timestampLabel}
isOn={timestampToggledOn}
onChange={() => toggleTimestamp(editorState)}
/>
)}
</FormInput>
</div>
);
}
}
export default DraftJSMentionSelector;