/** * @flow * @file Content Preview Component * @author Box */ import 'regenerator-runtime/runtime'; import * as React from 'react'; import classNames from 'classnames'; import cloneDeep from 'lodash/cloneDeep'; import flow from 'lodash/flow'; import getProp from 'lodash/get'; import isEqual from 'lodash/isEqual'; import noop from 'lodash/noop'; import omit from 'lodash/omit'; import setProp from 'lodash/set'; import throttle from 'lodash/throttle'; import uniqueid from 'lodash/uniqueId'; import Measure from 'react-measure'; import { withRouter } from 'react-router-dom'; import type { ContextRouter } from 'react-router-dom'; import { decode } from '../../utils/keys'; import makeResponsive from '../common/makeResponsive'; import { withNavRouter } from '../common/nav-router'; import Internationalize from '../common/Internationalize'; import AsyncLoad from '../common/async-load'; // $FlowFixMe TypeScript file import ThemingStyles from '../common/theming'; // $FlowFixMe TypeScript file import PreviewContext from './PreviewContext'; import TokenService from '../../utils/TokenService'; import { isInputElement, focus } from '../../utils/dom'; import { getTypedFileId } from '../../utils/file'; import { withAnnotations, withAnnotatorContext } from '../common/annotator-context'; import { withErrorBoundary } from '../common/error-boundary'; import { withLogger } from '../common/logger'; import { PREVIEW_FIELDS_TO_FETCH } from '../../utils/fields'; import { mark } from '../../utils/performance'; import { withFeatureConsumer, withFeatureProvider } from '../common/feature-checking'; // $FlowFixMe import { withBlueprintModernization } from '../common/withBlueprintModernization'; import { EVENT_JS_READY } from '../common/logger/constants'; import ReloadNotification from './ReloadNotification'; import API from '../../api'; import APIContext from '../common/api-context'; import PreviewHeader from './preview-header'; import PreviewMask from './PreviewMask'; import PreviewNavigation from './PreviewNavigation'; import Providers from '../common/Providers'; import { DEFAULT_HOSTNAME_API, DEFAULT_HOSTNAME_APP, DEFAULT_HOSTNAME_STATIC, DEFAULT_PREVIEW_VERSION, DEFAULT_LOCALE, DEFAULT_PATH_STATIC_PREVIEW, CLIENT_NAME_CONTENT_PREVIEW, CLIENT_VERSION, ORIGIN_PREVIEW, ORIGIN_CONTENT_PREVIEW, ERROR_CODE_UNKNOWN, } from '../../constants'; import type { Annotation } from '../../common/types/feed'; import type { Target } from '../../common/types/annotations'; import type { TargetingApi } from '../../features/targeting/types'; import type { ErrorType, AdditionalVersionInfo } from '../common/flowTypes'; import type { WithLoggerProps } from '../../common/types/logging'; import type { RequestOptions, ErrorContextProps, ElementsXhrError } from '../../common/types/api'; import type { StringMap, Token, BoxItem, BoxItemVersion } from '../../common/types/core'; import type { VersionChangeCallback } from '../content-sidebar/versions'; import type { FeatureConfig } from '../common/feature-checking'; import type { WithAnnotationsProps, WithAnnotatorContextProps } from '../common/annotator-context'; // $FlowFixMe TypeScript file import type { Theme } from '../common/theming'; import type APICache from '../../utils/Cache'; import '../common/fonts.scss'; import '../common/base.scss'; import './ContentPreview.scss'; type StartAt = { unit: 'pages' | 'seconds', value: number, }; type Props = { advancedContentInsights: { isActive: boolean, ownerEId: number, userId: number, }, apiHost: string, appHost: string, autoFocus: boolean, boxAnnotations?: Object, cache?: APICache, canDownload?: boolean, className: string, collection: Array, contentAnswersProps: ContentAnswersProps, contentOpenWithProps: ContentOpenWithProps, contentSidebarProps: ContentSidebarProps, enableThumbnailsSidebar: boolean, features?: FeatureConfig, fileId?: string, fileOptions?: Object, getInnerRef: () => ?HTMLElement, hasHeader?: boolean, hasProviders?: boolean, isLarge: boolean, isVeryLarge?: boolean, language: string, logoUrl?: string, measureRef: Function, messages?: StringMap, onAnnotator: Function, onAnnotatorEvent: Function, onClose?: Function, onContentInsightsEventReport: Function, onDownload: Function, onLoad: Function, onNavigate: Function, onVersionChange: VersionChangeCallback, previewExperiences?: { [name: string]: TargetingApi, }, previewLibraryVersion: string, requestInterceptor?: Function, responseInterceptor?: Function, sharedLink?: string, sharedLinkPassword?: string, showAnnotations?: boolean, showAnnotationsControls?: boolean, staticHost: string, staticPath: string, theme?: Theme, token: Token, useHotkeys: boolean, } & ErrorContextProps & WithLoggerProps & WithAnnotationsProps & WithAnnotatorContextProps & ContextRouter; type State = { canPrint?: boolean, currentFileId?: string, error?: ErrorType, file?: BoxItem, isLoading: boolean, isReloadNotificationVisible: boolean, isThumbnailSidebarOpen: boolean, prevFileIdProp?: string, // the previous value of the "fileId" prop. Needed to implement getDerivedStateFromProps selectedVersion?: BoxItemVersion, startAt?: StartAt, }; // Emitted by preview's 'load' event type PreviewTimeMetrics = { conversion: number, preload?: number, rendering: number, total: number, }; // Emitted by preview's 'preview_metric' event type PreviewMetrics = { browser_name: string, client_version: string, content_type: string, // Sum of all available load times. convert_time: number, download_response_time: number, error?: Object, event_name?: string, extension: string, file_id: string, file_info_time: number, file_version_id: string, full_document_load_time: number, locale: string, logger_session_id: string, rep_type: string, timestamp: string, value: number, }; type PreviewLibraryError = { error: ErrorType, }; const startAtTypes = { page: 'pages', }; const InvalidIdError = new Error('Invalid id for Preview!'); const PREVIEW_LOAD_METRIC_EVENT = 'load'; const MARK_NAME_JS_READY = `${ORIGIN_CONTENT_PREVIEW}_${EVENT_JS_READY}`; const SCROLL_TO_ANNOTATION_EVENT = 'scrolltoannotation'; mark(MARK_NAME_JS_READY); const LoadableSidebar = AsyncLoad({ loader: () => import(/* webpackMode: "lazy", webpackChunkName: "content-sidebar" */ '../content-sidebar'), }); class ContentPreview extends React.PureComponent { id: string; props: Props; state: State; preview: any; api: API; // Defines a generic type for ContentSidebar, since an import would interfere with code splitting contentSidebar: { current: null | { refresh: Function } } = React.createRef(); previewBodyRef = React.createRef(); previewContextValue = { previewBodyRef: this.previewBodyRef }; previewContainer: ?HTMLDivElement; mouseMoveTimeoutID: TimeoutID; rootElement: HTMLElement; stagedFile: ?BoxItem; previewLibraryLoaded: boolean = false; updateVersionToCurrent: ?() => void; dynamicOnPreviewLoadAction: ?() => void; initialState: State = { canPrint: false, error: undefined, isLoading: true, isReloadNotificationVisible: false, isThumbnailSidebarOpen: false, }; static defaultProps = { apiHost: DEFAULT_HOSTNAME_API, appHost: DEFAULT_HOSTNAME_APP, autoFocus: false, canDownload: true, className: '', collection: [], contentAnswersProps: {}, contentOpenWithProps: {}, contentSidebarProps: {}, enableThumbnailsSidebar: false, hasHeader: false, language: DEFAULT_LOCALE, onAnnotator: noop, onAnnotatorEvent: noop, onContentInsightsEventReport: noop, onDownload: noop, onError: noop, onLoad: noop, onNavigate: noop, onPreviewDestroy: noop, onVersionChange: noop, previewLibraryVersion: DEFAULT_PREVIEW_VERSION, showAnnotations: false, staticHost: DEFAULT_HOSTNAME_STATIC, staticPath: DEFAULT_PATH_STATIC_PREVIEW, useHotkeys: true, }; /** * @property {number} */ fetchFileEndTime: ?number; /** * @property {number} */ fetchFileStartTime: ?number; /** * [constructor] * * @return {ContentPreview} */ constructor(props: Props) { super(props); const { apiHost, cache, fileId, language, requestInterceptor, responseInterceptor, sharedLink, sharedLinkPassword, token, } = props; this.id = uniqueid('bcpr_'); this.api = new API({ apiHost, cache, clientName: CLIENT_NAME_CONTENT_PREVIEW, language, requestInterceptor, responseInterceptor, sharedLink, sharedLinkPassword, token, version: CLIENT_VERSION, }); this.state = { ...this.initialState, currentFileId: fileId, // eslint-disable-next-line react/no-unused-state prevFileIdProp: fileId, }; const { logger } = props; logger.onReadyMetric({ endMarkName: MARK_NAME_JS_READY, }); } /** * Cleanup * * @return {void} */ componentWillUnmount(): void { // Don't destroy the cache while unmounting this.api.destroy(false); this.destroyPreview(); } /** * Cleans up the preview instance */ destroyPreview(shouldReset: boolean = true) { const { onPreviewDestroy } = this.props; if (this.preview) { this.preview.destroy(); this.preview.removeAllListeners(); this.preview = undefined; onPreviewDestroy(shouldReset); } } /** * Destroys api instances with caches * * @private * @return {void} */ clearCache(): void { this.api.destroy(true); } /** * Once the component mounts, load Preview assets and fetch file info. * * @return {void} */ componentDidMount(): void { this.loadStylesheet(); this.loadScript(); this.fetchFile(this.state.currentFileId); this.focusPreview(); } static getDerivedStateFromProps(props: Props, state: State) { const { fileId } = props; if (fileId !== state.prevFileIdProp) { return { currentFileId: fileId, prevFileIdProp: fileId, }; } return null; } /** * After component updates, load Preview if appropriate. * * @return {void} */ componentDidUpdate(prevProps: Props, prevState: State): void { const { features, previewExperiences, token } = this.props; const { features: prevFeatures, previewExperiences: prevPreviewExperiences, token: prevToken } = prevProps; const { currentFileId } = this.state; const hasFileIdChanged = prevState.currentFileId !== currentFileId; const hasTokenChanged = prevToken !== token; const haveAdvancedContentInsightsChanged = !isEqual( prevFeatures?.advancedContentInsights, features?.advancedContentInsights, ); const haveExperiencesChanged = prevPreviewExperiences !== previewExperiences; if (hasFileIdChanged) { this.destroyPreview(); this.setState({ isLoading: true, selectedVersion: undefined }); this.fetchFile(currentFileId); } else if (this.shouldLoadPreview(prevState)) { this.destroyPreview(false); this.setState({ isLoading: true }); this.loadPreview(); } else if (hasTokenChanged) { this.updatePreviewToken(); } if (haveExperiencesChanged && this.preview && this.preview.updateExperiences) { this.preview.updateExperiences(previewExperiences); } if ( this.preview?.updateContentInsightsOptions && features?.advancedContentInsights && haveAdvancedContentInsightsChanged ) { this.preview.updateContentInsightsOptions(features?.advancedContentInsights); } } /** * Updates the access token used by preview library * * @param {boolean} shouldReload - true if preview should be reloaded */ updatePreviewToken(shouldReload: boolean = false) { if (this.preview) { this.preview.updateToken(this.props.token, shouldReload); } } /** * Returns whether or not preview should be loaded. * * @param {State} prevState - Previous state * @return {boolean} */ shouldLoadPreview(prevState: State): boolean { const { file, selectedVersion }: State = this.state; const { file: prevFile, selectedVersion: prevSelectedVersion }: State = prevState; const prevSelectedVersionId = getProp(prevSelectedVersion, 'id'); const selectedVersionId = getProp(selectedVersion, 'id'); const prevFileVersionId = getProp(prevFile, 'file_version.id'); const fileVersionId = getProp(file, 'file_version.id'); let loadPreview = false; // Check if preview library just became available and we haven't loaded preview yet // This handles cases where library loads asynchronously after file is already set if (!this.previewLibraryLoaded && this.isPreviewLibraryLoaded() && file && !this.preview) { this.previewLibraryLoaded = true; return true; } if (selectedVersionId !== prevSelectedVersionId) { const isPreviousCurrent = fileVersionId === prevSelectedVersionId || !prevSelectedVersionId; const isSelectedCurrent = fileVersionId === selectedVersionId || !selectedVersionId; // Load preview if the user has selected a non-current version of the file loadPreview = !isPreviousCurrent || !isSelectedCurrent; } else if (fileVersionId && prevFileVersionId) { // Load preview if the file's current version ID has changed loadPreview = fileVersionId !== prevFileVersionId; } else { // Load preview if file object has newly been populated in state loadPreview = !prevFile && !!file; } return loadPreview; } /** * Returns preview asset urls * * @return {string} base url */ getBasePath(asset: string): string { const { staticHost, staticPath, language, previewLibraryVersion }: Props = this.props; const path: string = `${staticPath}/${previewLibraryVersion}/${language}/${asset}`; const suffix: string = staticHost.endsWith('/') ? path : `/${path}`; return `${staticHost}${suffix}`; } /** * Determines if preview assets are loaded * * @return {boolean} true if preview is loaded */ isPreviewLibraryLoaded(): boolean { return !!global.Box && !!global.Box.Preview; } /** * Loads external css by appending a element * * @return {void} */ loadStylesheet(): void { const { head } = document; const url: string = this.getBasePath('preview.css'); if (!head || head.querySelector(`link[rel="stylesheet"][href="${url}"]`)) { return; } const link = document.createElement('link'); link.rel = 'stylesheet'; link.type = 'text/css'; link.href = url; head.appendChild(link); } /** * Loads external script by appending a