/** * @flow * @file Content Preview Component * @author Box */ import 'regenerator-runtime/runtime'; import * as React from 'react'; import classNames from 'classnames'; import uniqueid from 'lodash/uniqueId'; import throttle from 'lodash/throttle'; import cloneDeep from 'lodash/cloneDeep'; import omit from 'lodash/omit'; import getProp from 'lodash/get'; import flow from 'lodash/flow'; import noop from 'lodash/noop'; import Measure from 'react-measure'; import type { RouterHistory } from 'react-router-dom'; import { decode } from '../../utils/keys'; import makeResponsive from '../common/makeResponsive'; import Internationalize from '../common/Internationalize'; import AsyncLoad from '../common/async-load'; import TokenService from '../../utils/TokenService'; import { isInputElement, focus } from '../../utils/dom'; import { getTypedFileId } from '../../utils/file'; 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 { withFeatureProvider } from '../common/feature-checking'; import { EVENT_JS_READY } from '../common/logger/constants'; import ReloadNotification from './ReloadNotification'; import API from '../../api'; import PreviewHeader from './preview-header'; import PreviewNavigation from './PreviewNavigation'; import PreviewLoading from './PreviewLoading'; import { DEFAULT_HOSTNAME_API, DEFAULT_HOSTNAME_APP, DEFAULT_HOSTNAME_STATIC, DEFAULT_PREVIEW_VERSION, DEFAULT_LOCALE, DEFAULT_PATH_STATIC_PREVIEW, CLIENT_NAME_CONTENT_PREVIEW, ORIGIN_PREVIEW, ORIGIN_CONTENT_PREVIEW, ERROR_CODE_UNKNOWN, } from '../../constants'; import type { ErrorType } from '../common/flowTypes'; import type { VersionChangeCallback } from '../content-sidebar/versions'; import '../common/fonts.scss'; import '../common/base.scss'; import './ContentPreview.scss'; type Props = { apiHost: string, appHost: string, autoFocus: boolean, cache?: APICache, canDownload?: boolean, className: string, collection: Array, contentOpenWithProps: ContentOpenWithProps, contentSidebarProps: ContentSidebarProps, enableThumbnailsSidebar: boolean, features?: FeatureConfig, fileId?: string, fileOptions?: Object, getInnerRef: () => ?HTMLElement, hasHeader?: boolean, history?: RouterHistory, isLarge: boolean, isVeryLarge?: boolean, language: string, logoUrl?: string, measureRef: Function, messages?: StringMap, onClose?: Function, onDownload: Function, onLoad: Function, onNavigate: Function, onVersionChange: VersionChangeCallback, previewLibraryVersion: string, requestInterceptor?: Function, responseInterceptor?: Function, sharedLink?: string, sharedLinkPassword?: string, showAnnotations?: boolean, staticHost: string, staticPath: string, token: Token, useHotkeys: boolean, } & ErrorContextProps & WithLoggerProps; type State = { currentFileId?: string, error?: ErrorType, file?: BoxItem, isReloadNotificationVisible: boolean, isThumbnailSidebarOpen: boolean, prevFileIdProp?: string, // the previous value of the "fileId" prop. Needed to implement getDerivedStateFromProps selectedVersion?: BoxItemVersion, }; // 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 InvalidIdError = new Error('Invalid id for Preview!'); const PREVIEW_LOAD_METRIC_EVENT = 'load'; const MARK_NAME_JS_READY = `${ORIGIN_CONTENT_PREVIEW}_${EVENT_JS_READY}`; 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(); previewContainer: ?HTMLDivElement; mouseMoveTimeoutID: TimeoutID; rootElement: HTMLElement; stagedFile: ?BoxItem; updateVersionToCurrent: ?() => void; initialState: State = { error: undefined, isReloadNotificationVisible: false, isThumbnailSidebarOpen: false, }; static defaultProps = { apiHost: DEFAULT_HOSTNAME_API, appHost: DEFAULT_HOSTNAME_APP, autoFocus: false, canDownload: true, className: '', collection: [], contentOpenWithProps: {}, contentSidebarProps: {}, enableThumbnailsSidebar: false, hasHeader: false, language: DEFAULT_LOCALE, onDownload: noop, onError: noop, onLoad: noop, onNavigate: 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, }); 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() { if (this.preview) { this.preview.destroy(); this.preview.removeAllListeners(); this.preview = undefined; } this.setState({ selectedVersion: undefined }); } /** * 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 { token } = this.props; const { currentFileId } = this.state; const hasFileIdChanged = prevState.currentFileId !== currentFileId; const hasTokenChanged = prevProps.token !== token; if (hasFileIdChanged) { this.destroyPreview(); this.fetchFile(currentFileId); } else if (this.shouldLoadPreview(prevState)) { this.loadPreview(); } else if (hasTokenChanged) { this.updatePreviewToken(); } } /** * 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; 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