box-ui-elements
Version:
Box UI Elements
536 lines (512 loc) • 21.4 kB
JavaScript
function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; }
function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
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 Toggle from '../../toggle/Toggle';
import { UNEDITABLE_TIMESTAMP_TEXT } from './utils';
import { convertSecondsToHMMSS } from '../../../utils/timestamp';
/**
* 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, callback, contentState) => {
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);
};
class DraftJSMentionSelector extends React.Component {
constructor(props) {
super(props);
_defineProperty(this, "getIsVideoTimestampEnabled", () => {
const {
timestampLabel
} = this.props;
return !!timestampLabel && timestampLabel.trim() !== '';
});
_defineProperty(this, "toggleTimestamp", (editorState, forceOn = 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);
});
/**
* Event handler called on blur. Triggers validation
* @param {SyntheticFocusEvent} event The event object
* @returns {void}
*/
_defineProperty(this, "handleBlur", event => {
if (this.props.validateOnBlur && this.containerEl && event.relatedTarget instanceof Node && !this.containerEl.contains(event.relatedTarget)) {
this.checkValidity();
}
});
_defineProperty(this, "handleFocus", event => {
const {
onFocus
} = this.props;
if (onFocus) {
onFocus(event);
}
});
_defineProperty(this, "getIsTimestampEntityPresent", currentContent => {
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)
*/
_defineProperty(this, "getTimestampLength", currentContent => {
// $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}
*/
_defineProperty(this, "handleChange", nextEditorState => {
const {
internalEditorState,
isTimestampToggledOn
} = this.state;
const {
onChange
} = 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);
}
});
_defineProperty(this, "handleValidityStateUpdateHandler", () => {
const {
isTouched
} = this.state;
if (!isTouched) {
return;
}
const error = this.getErrorFromValidityState();
this.setState({
error
});
});
_defineProperty(this, "checkValidity", () => {
this.handleValidityStateUpdateHandler();
});
_defineProperty(this, "getVideoTimestamp", () => {
const videoContainer = document.querySelector('.bp-media-container');
// $FlowFixMe
const video = 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
};
});
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) {
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, prevState) {
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);
}
}
getDerivedStateFromEditorState(currentEditorState, previousEditorState) {
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;
}
checkValidityIfAllowed() {
const {
validateOnBlur
} = this.props;
if (!validateOnBlur) {
this.checkValidity();
}
}
isEditorStateEmpty(editorState) {
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 = 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;
}
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 = internalEditorState || externalEditorState;
// Ensure the editor state has the composite decorator
if (editorState.getDecorator() !== this.compositeDecorator) {
editorState = EditorState.set(editorState, {
decorator: this.compositeDecorator
});
}
return /*#__PURE__*/React.createElement("div", {
ref: containerEl => {
this.containerEl = containerEl;
},
className: className
}, /*#__PURE__*/React.createElement(FormInput, {
name: name,
onValidityStateUpdate: this.handleValidityStateUpdateHandler
}, /*#__PURE__*/React.createElement(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() && /*#__PURE__*/React.createElement(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)
})));
}
}
_defineProperty(DraftJSMentionSelector, "defaultProps", {
isRequired: false,
onChange: noop,
validateOnBlur: true
});
export default DraftJSMentionSelector;
//# sourceMappingURL=DraftJSMentionSelector.js.map