UNPKG

itsa-react-docviewer

Version:

React component that views documents using Google Doc Viewer

429 lines (396 loc) 11.2 kB
/** * React component that views documents. * Supports Google Doc Viewer for remote documents and native browser viewing * for local/browser-compatible files (PDF, images, HTML, text, SVG). * * * <i>Copyright (c) 2016 ItsAsbreuk - http://itsasbreuk.nl</i><br> * New BSD License - http://choosealicense.com/licenses/bsd-3-clause/ * * * @module component.jsx * @class Component * @since 15.0.0 */ import "itsa-jsext"; import React from "react"; import itsaUtils from "itsa-utils"; import PropTypes from "prop-types"; const IS_NODE = itsaUtils.isNode; const GOOGLE_VIEWER_URL = "https://docs.google.com/viewer?embedded=true&url=", MAIN_CLASS = "itsa-docviewer", MAIN_CLASS_PREFIX = MAIN_CLASS + "-", BROWSER_NATIVE_EXTENSIONS = [ ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp", ".avif", ".ico", ".html", ".htm", ".txt", ".csv", ".xml", ".json", ".css", ".js", ".mp4", ".webm", ".ogg", ".mp3", ".wav", ]; /** * Determines the effective viewer mode based on props and file extension. * * @method getViewerMode * @param {String} viewer - The viewer prop value: 'auto', 'google', or 'browser' * @param {String} src - The source URL * @return {String} 'google' or 'browser' * @private * @since 17.1.0 */ const getViewerMode = function (viewer, src) { if (viewer === "google" || viewer === "browser") { return viewer; } // auto mode: detect from file extension if (src) { // strip query string and hash, then get extension const cleanPath = src.split("?")[0].split("#")[0].toLowerCase(); const dotIndex = cleanPath.lastIndexOf("."); if (dotIndex !== -1) { const ext = cleanPath.substring(dotIndex); if (BROWSER_NATIVE_EXTENSIONS.indexOf(ext) !== -1) { return "browser"; } } } return "google"; }; // polyfill Element.requestFullscreen: if (!IS_NODE) { (function (ElementPrototype) { ElementPrototype.requestFullscreen || (ElementPrototype.requestFullscreen = ElementPrototype.webkitRequestFullscreen || ElementPrototype.webkitRequestFullScreen || ElementPrototype.mozRequestFullScreen || ElementPrototype.msRequestFullscreen); })(window.Element.prototype); } class Component extends React.Component { constructor(props) { super(props); const instance = this; instance.state = { loading: true, blobUrl: null, // holds the blob URL for browser-mode viewing }; instance._mounted = true; instance._ready = Promise.itsa_manage(); instance.fullScreen = instance.fullScreen.bind(instance); instance.handleSrcLoad = instance.handleSrcLoad.bind(instance); } componentDidMount() { this._fetchBlobIfNeeded(); } componentDidUpdate(prevProps) { const instance = this, props = instance.props; if (prevProps.src !== props.src || prevProps.viewer !== props.viewer) { instance._revokeBlobUrl(); instance._ready = Promise.itsa_manage(); if (props.src) { instance.setState( { loading: true, blobUrl: null, }, function () { instance._fetchBlobIfNeeded(); }, ); } else { instance.setState({ loading: false, blobUrl: null }, function () { instance._ready.fulfill(); }); } } } componentWillUnmount() { this._mounted = false; this._revokeBlobUrl(); } /** * Revokes the current blob URL to free memory. * * @method _revokeBlobUrl * @private * @since 17.2.1 */ _revokeBlobUrl() { if (this.state.blobUrl) { // Strip any fragment (e.g. #filename.pdf) before revoking URL.revokeObjectURL(this.state.blobUrl.split("#")[0]); } } /** * Fetches the document as a blob and creates a blob URL when in browser mode. * This bypasses X-Frame-Options and Content-Security-Policy restrictions * that prevent documents from being loaded directly in iframes. * * @method _fetchBlobIfNeeded * @private * @since 17.2.1 */ _fetchBlobIfNeeded() { const instance = this, props = instance.props, src = props.src; if (IS_NODE || !src) return; const viewerMode = getViewerMode(props.viewer, src); if (viewerMode !== "browser") return; // Fetch the file as a blob to bypass X-Frame-Options / CSP framing restrictions fetch(src) .then(function (response) { if (!response.ok) { throw new Error("Failed to fetch document (" + response.status + ")"); } return response.blob(); }) .then(function (blob) { if (!instance._mounted) { return; } const rawBlobUrl = URL.createObjectURL(blob); // Extract filename from the source URL and append as fragment // so the browser's built-in PDF viewer can display it const urlPath = src.split("?")[0].split("#")[0]; const fileName = urlPath.substring(urlPath.lastIndexOf("/") + 1); const blobUrl = fileName ? rawBlobUrl + "#" + fileName : rawBlobUrl; instance.setState({ blobUrl: blobUrl }); }) .catch(function (err) { // Fallback: use the original URL directly (may still work if no framing restrictions) console.warn( "itsa-react-docviewer: blob fetch failed, falling back to direct URL:", err.message, ); if (instance._mounted) { instance.setState({ blobUrl: null }); } }); } /** * Will show the content into the full screen. Only if `props.allowFullScreen`===true. * * @method fullScreen * @since 0.0.1 */ fullScreen() { this.props.allowFullScreen && this._iframeNode.requestFullscreen && this._iframeNode.requestFullscreen(); } /** * Prints the document content. Returns a Promise that resolves after printing. * * @method print * @return Promise * @since 15.0.0 */ print() { // return a Promise! const instance = this; if (instance.props.src) { return instance._ready.then(function () { if (instance._mounted && instance._iframeNode) { const win = instance._iframeNode.contentWindow || instance._iframeNode; if (typeof win.print === "function") { win.print(); } } }); } return Promise.resolve(); } /** * Hides the "load-message" as specified by this.props.loadingMsg * * @method handleSrcLoad * @since 16.0.5 */ handleSrcLoad() { const instance = this; if (instance._mounted) { instance.setState({ loading: false }, function () { instance._ready.fulfill(); }); } } /** * React render-method --> renderes the Component. * * @method render * @return ReactComponent * @since 15.0.0 */ render() { let className = MAIN_CLASS, source, fullscreenBtn, scrolling, loadingMsg; const instance = this, props = instance.props, propsClass = props.className; scrolling = props.scrolling; typeof scrolling === "boolean" && (scrolling = scrolling.toString()); source = props.src; if (!IS_NODE && source) { const viewerMode = getViewerMode(props.viewer, source); if (viewerMode === "google") { // Google Doc Viewer: needs an absolute, publicly accessible URL if ( source.substr(0, 7).toLowerCase() !== "http://" && source.substr(0, 8).toLowerCase() !== "https://" ) { source = window.location.protocol + "//" + window.location.host + source; } source = GOOGLE_VIEWER_URL + encodeURIComponent(source); } else { // 'browser' mode: use blob URL if available (bypasses X-Frame-Options / CSP), // otherwise fall back to the original source URL if (instance.state.blobUrl) { source = instance.state.blobUrl; } } } propsClass && (className += " " + propsClass); if (props.allowFullScreen) { fullscreenBtn = ( <div className={MAIN_CLASS_PREFIX + "full-screen"} onClick={instance.fullScreen} /> ); } if (instance.state.loading && props.showLoadingMsg) { loadingMsg = ( <div className={MAIN_CLASS_PREFIX + "loading-msg"}> <div>{props.loadingMsg}</div> </div> ); } return ( <div className={className} style={props.style}> <iframe allowFullScreen={props.allowFullScreen} frameBorder="0" height="100%" onLoad={instance.handleSrcLoad} ref={(node) => (instance._iframeNode = node)} scrolling={scrolling} src={source} width="100%" /> {fullscreenBtn} {loadingMsg} </div> ); } } Component.displayName = "DocViewer"; Component.propTypes = { /** * Allowed to be displayed into full screen mode, by calling `fullScreen()` * * @property allowFullScreen * @type Boolean * @since 15.0.0 */ allowFullScreen: PropTypes.bool, /** * Any class to be set on the main component. * * @property className * @type String * @since 15.0.0 */ className: PropTypes.string, /** * The message that shows while the document gets loaded * * @property loadingMsg * @default "loading..." * @type String * @since 16.0.5 */ loadingMsg: PropTypes.string, /** * Whether the browser should provide a scroll bar when needed. * Either `auto`, `yes` or `no` * * @property scrolling * @default "auto" * @type String|Boolean * @since 15.0.0 */ scrolling: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), /** * Whether to show a message (this.props.loadingMsg) during document load * * @property showLoadingMsg * @default false * @type Boolean * @since 16.0.5 */ showLoadingMsg: PropTypes.bool, /** * The url of the document to be viewed. May be absolute or relative. * * @property src * @type String * @since 15.0.0 */ src: PropTypes.string, /** * Inline styles for the main wrapper div. * * @property style * @type Object * @since 15.0.0 */ style: PropTypes.object, /** * Which viewer to use for rendering the document. * * - `"auto"` (default): Auto-detect based on file extension. Browser-native formats * (PDF, images, HTML, text, SVG, video, audio) use the browser's built-in viewer. * All other formats use Google Doc Viewer. * - `"google"`: Always use Google Doc Viewer (requires publicly accessible URL). * - `"browser"`: Always use the browser's native rendering via iframe * (works with local/relative paths, Blob URLs, and data URIs). * * @property viewer * @default "auto" * @type String * @since 17.1.0 */ viewer: PropTypes.oneOf(["auto", "google", "browser"]), }; Component.defaultProps = { loadingMsg: "loading...", scrolling: "auto", showLoadingMsg: false, viewer: "auto", }; export default Component;