Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Add PDF Viewer and size limits to file preview
  • Loading branch information
Polleps committed Mar 1, 2023
commit bbdcfdecd8bb0d64443602f56c33308f58343c0d
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ This changelog covers all three packages, as they are (for now) updated as a who
- `store.createSubject` allows creating nested paths
- Add `useChildren` hook and `Store.getChildren` method
- Add `Store.postToServer` method, add `endpoints`, `importJsonAdString`
- Add new file preview UI for images, audio, text and PDF files.
- Add new file preview types to the folder grid view.

## v0.35.0

Expand Down
1 change: 1 addition & 0 deletions data-browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ You can set the Agent on the `/app/agent` route.
- **Accessing the store from the browser console** can be done in develop mode in your browser with the global `store` object.
- **Forms** use the various value hooks (e.g. `useString`) for maintaining actual resource state. When the form input changes, the new value will be `.set()` on the `Resource`, and this will throw an error if there is a validation error. These should be catched by passing an error handler to the `useString` hook.
- **Error handling** is set in `App.tsx` on initialization. We set `Store.errorHandler` which is called when something goes wrong. This should result in a toaster error shown to the user, and a message sent to BugSnag if `window.bugsnagApiKey` is set.
- **Bundle Splitting** is used for components that use a large dependancy that is not vital to the main application. These component are located in the `chunks` folder and should **always** be imported dynamically to avoid adding the dependancy to the main bundle.

## Directory structure

Expand Down
2 changes: 2 additions & 0 deletions data-browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"react-intersection-observer": "^9.4.1",
"react-is": "^18",
"react-markdown": "^8.0.3",
"react-pdf": "^6.2.2",
"react-router": "^6.0.0",
"react-router-dom": "^6.0.0",
"remark-gfm": "^3.0.1",
Expand All @@ -35,6 +36,7 @@
"devDependencies": {
"@types/react": "^18.0.10",
"@types/react-dom": "^18.0.5",
"@types/react-pdf": "^6.2.0",
"@types/react-router-dom": "^5.0.0",
"@types/styled-components": "^5.1.25",
"babel-plugin-styled-components": "^2.0.7",
Expand Down
64 changes: 64 additions & 0 deletions data-browser/src/chunks/PDFViewer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, { useCallback, useMemo, useState } from 'react';
import { pdfjs, Document, Page } from 'react-pdf';
import 'react-pdf/dist/esm/Page/TextLayer.css';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import styled from 'styled-components';

pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`;
interface PDFViewerProps {
url: string;
className?: string;
}

export default function PDFViewer({
url,
className,
}: PDFViewerProps): JSX.Element {
const [numberOfPages, setNumberOfPages] = useState<number>(0);
const handleError = useCallback((error: Error) => console.error(error), []);

const handleDocumentLoadSuccess = useCallback(
({ numPages }: { numPages: number }) => {
setNumberOfPages(numPages);
},
[],
);

const file = useMemo(() => {
return {
url: url,
withCredentials: true,
};
}, [url]);

return (
<StyledDocument
file={file}
className={className}
onLoadSuccess={handleDocumentLoadSuccess}
onLoadError={handleError}
onSourceError={handleError}
>
{Array.from(new Array(numberOfPages), (el, index) => (
<StyledPage key={`page_${index + 1}`} pageNumber={index + 1} />
))}
</StyledDocument>
);
}

const StyledDocument = styled(Document)`
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
overflow-x: auto;
overflow-y: visible;
padding-bottom: 1rem;
`;

const StyledPage = styled(Page)`
margin: auto;
border-radius: ${({ theme }) => theme.radius};
overflow: hidden;
box-shadow: ${({ theme }) => theme.boxShadow};
`;
1 change: 1 addition & 0 deletions data-browser/src/components/ImageViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function ImageViewer({
alt={alt ?? ''}
className={className}
data-test={`image-viewer`}
loading='lazy'
/>
)}
{showFull &&
Expand Down
8 changes: 8 additions & 0 deletions data-browser/src/hooks/useFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ export function useFileInfo(resource: Resource) {
window.open(downloadUrl);
}, [downloadUrl]);

if (
downloadUrl === undefined ||
mimeType === undefined ||
bytes === undefined
) {
throw new Error('File resource is missing properties');
}

return {
downloadFile,
downloadUrl,
Expand Down
21 changes: 21 additions & 0 deletions data-browser/src/hooks/useFilePreviewSizeLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import { useMediaQuery } from './useMediaQuery';

const DEFAULT_FILE_SIZE_LIMIT = 10 * 1024 * 1024; // 10 MB
const REDUCED_FILE_SIZE_LIMIT = 1024 * 100; // 100 KB

export function useFilePreviewSizeLimit() {
const [limit, setLimit] = useState(DEFAULT_FILE_SIZE_LIMIT);

const prefersReducedData = useMediaQuery('(prefers-reduced-data: reduce)');

useEffect(() => {
if (prefersReducedData) {
setLimit(REDUCED_FILE_SIZE_LIMIT);
} else {
setLimit(DEFAULT_FILE_SIZE_LIMIT);
}
}, [prefersReducedData]);

return limit;
}
4 changes: 2 additions & 2 deletions data-browser/src/hooks/useMediaQuery.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';

/** Watches a media query and returns a statefull result. */
export function useMediaQuery(query: string, def = false): boolean {
const [matches, setMatches] = useState(def);
export function useMediaQuery(query: string, initial = false): boolean {
const [matches, setMatches] = useState(initial);

useEffect(() => {
if (!window.matchMedia) {
Expand Down
6 changes: 5 additions & 1 deletion data-browser/src/views/ErrorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export default ErrorPage;

interface ErrorBoundaryProps {
children: React.ReactNode;
FallBackComponent: React.ComponentType;
FallBackComponent?: React.ComponentType<{ error: Error }>;
}

interface ErrorBoundaryState {
Expand All @@ -100,6 +100,10 @@ export class ErrorBoundary extends React.Component<

public render() {
if (this.state.error) {
if (this.props.FallBackComponent) {
return <this.props.FallBackComponent error={this.state.error} />;
}

return (
<CrashPage
error={this.state.error}
Expand Down
21 changes: 20 additions & 1 deletion data-browser/src/views/File/DownloadButton.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import React from 'react';
import { FaDownload } from 'react-icons/fa';
import styled from 'styled-components';
import { Button } from '../../components/Button';
import { IconButton } from '../../components/IconButton/IconButton';
import { Row } from '../../components/Row';
import { displayFileSize } from './displayFileSize';

interface DownloadButtonProps {
downloadFile: () => void;
fileSize?: number;
}

export function DownloadButton({
export function DownloadIconButton({
downloadFile,
fileSize,
}: DownloadButtonProps): JSX.Element {
Expand All @@ -26,3 +28,20 @@ export function DownloadButton({
const DownloadIcon = styled(FaDownload)`
color: ${({ theme }) => theme.colors.main};
`;

export function DownloadButton({
downloadFile,
fileSize,
}: DownloadButtonProps): JSX.Element {
return (
<Button
onClick={downloadFile}
title={`Download file (${displayFileSize(fileSize ?? 0)})`}
>
<Row gap='0.5rem'>
<FaDownload />
Download
</Row>
</Button>
);
}
4 changes: 2 additions & 2 deletions data-browser/src/views/File/FileCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { AtomicLink } from '../../components/AtomicLink';
import { Row } from '../../components/Row';
import { useFileInfo } from '../../hooks/useFile';
import { CardViewProps } from '../Card/CardViewProps';
import { DownloadButton } from './DownloadButton';
import { DownloadIconButton } from './DownloadButton';
import { FilePreview } from './FilePreview';

function FileCard({ resource }: CardViewProps): JSX.Element {
Expand All @@ -18,7 +18,7 @@ function FileCard({ resource }: CardViewProps): JSX.Element {
<AtomicLink subject={resource.getSubject()}>
<h2>{title}</h2>
</AtomicLink>
<DownloadButton downloadFile={downloadFile} fileSize={bytes} />
<DownloadIconButton downloadFile={downloadFile} fileSize={bytes} />
</Row>
<FilePreview resource={resource} />
</React.Fragment>
Expand Down
15 changes: 11 additions & 4 deletions data-browser/src/views/File/FilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,27 @@ import { EditableTitle } from '../../components/EditableTitle';
import { ValueForm } from '../../components/forms/ValueForm';
import { Column, Row } from '../../components/Row';
import { useFileInfo } from '../../hooks/useFile';
import { useMediaQuery } from '../../hooks/useMediaQuery';
import { ResourcePageProps } from '../ResourcePage';
import { DownloadButton } from './DownloadButton';
import { DownloadButton, DownloadIconButton } from './DownloadButton';
import { FilePreview } from './FilePreview';

/** Full page File resource for showing and downloading files */
export function FilePage({ resource }: ResourcePageProps) {
const { downloadFile, bytes } = useFileInfo(resource);
const wideScreen = useMediaQuery('(min-width: 600px)');

return (
<ContainerWide about={resource.getSubject()}>
<Column>
<Row center>
<DownloadButton downloadFile={downloadFile} fileSize={bytes} />
<Column gap='2rem'>
<Row center justify='space-between'>
<StyledEditableTitle resource={resource} />
{wideScreen && (
<DownloadButton downloadFile={downloadFile} fileSize={bytes} />
)}
{!wideScreen && (
<DownloadIconButton downloadFile={downloadFile} fileSize={bytes} />
)}
</Row>
<ValueForm resource={resource} propertyURL={properties.description} />
<FilePreview resource={resource} />
Expand Down
70 changes: 62 additions & 8 deletions data-browser/src/views/File/FilePreview.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
import React from 'react';
import React, { Suspense, useState } from 'react';
import { Resource } from '@tomic/react';
import { ImageViewer } from '../../components/ImageViewer';
import { useFileInfo } from '../../hooks/useFile';
import styled from 'styled-components';
import { TextPreview } from './TextPreview';
import { displayFileSize } from './displayFileSize';
import { Button } from '../../components/Button';
import { isTextFile } from './isTextFile';
import { useFilePreviewSizeLimit } from '../../hooks/useFilePreviewSizeLimit';

const PDFViewer = React.lazy(() => import('../../chunks/PDFViewer'));

interface FilePreviewProps {
resource: Resource;
}

export function FilePreview({ resource }: FilePreviewProps) {
const { downloadUrl, mimeType } = useFileInfo(resource);
const { downloadUrl, mimeType, bytes } = useFileInfo(resource);
const [ignoreSizeLimit, setIgnoreSizeLimit] = useState(false);
const fileSizeLimit = useFilePreviewSizeLimit();

if (bytes > fileSizeLimit && !ignoreSizeLimit) {
return (
<SizeWarning bytes={bytes} onClick={() => setIgnoreSizeLimit(true)} />
);
}

if (mimeType?.startsWith('image/')) {
return <StyledImageViewer src={downloadUrl ?? ''} />;
if (mimeType.startsWith('image/')) {
return <StyledImageViewer src={downloadUrl} />;
}

if (mimeType?.startsWith('video/')) {
if (mimeType.startsWith('video/')) {
return (
// Don't know how to get captions here
// eslint-disable-next-line jsx-a11y/media-has-caption
Expand All @@ -26,7 +41,7 @@ export function FilePreview({ resource }: FilePreviewProps) {
);
}

if (mimeType?.startsWith('audio/')) {
if (mimeType.startsWith('audio/')) {
return (
// eslint-disable-next-line jsx-a11y/media-has-caption
<audio controls>
Expand All @@ -35,8 +50,16 @@ export function FilePreview({ resource }: FilePreviewProps) {
);
}

if (mimeType?.startsWith('text/') || mimeType?.startsWith('application/')) {
return <TextPreview downloadUrl={downloadUrl ?? ''} mimeType={mimeType} />;
if (isTextFile(mimeType)) {
return <StyledTextPreview downloadUrl={downloadUrl} mimeType={mimeType} />;
}

if (mimeType === 'application/pdf') {
return (
<Suspense>
<PDFViewer url={downloadUrl} />
</Suspense>
);
}

return <NoPreview>No preview available</NoPreview>;
Expand All @@ -54,3 +77,34 @@ const NoPreview = styled.div`
background-color: ${({ theme }) => theme.colors.bg1};
height: 8rem;
`;

const StyledTextPreview = styled(TextPreview)`
width: 100%;
border: 1px solid ${({ theme }) => theme.colors.bg2};
background-color: ${({ theme }) => theme.colors.bg};
border-radius: ${({ theme }) => theme.radius};
padding: ${({ theme }) => theme.margin}rem;
`;

interface SizeWarningProps {
bytes: number;
onClick: () => void;
}

function SizeWarning({ bytes, onClick }: SizeWarningProps): JSX.Element {
const fileSizeLimit = useFilePreviewSizeLimit();

return (
<NoPreview>
<p>
Preview hidden because the file is larger than{' '}
{displayFileSize(fileSizeLimit)}.
</p>
<p>
<Button onClick={onClick}>
Load anyway ({displayFileSize(bytes)})
</Button>
</p>
</NoPreview>
);
}
Loading