From 797a59b32ed158649f2291c5d80f55bf04c18677 Mon Sep 17 00:00:00 2001 From: Cataldo Mazzilli Date: Fri, 13 Sep 2024 09:30:09 +0200 Subject: [PATCH] feat: handle video preview Refs: PREV-102 (#100) --- THIRDPARTIES | 2 - docs/api/carbonio-ui-preview.imagepreview.md | 2 +- docs/api/carbonio-ui-preview.md | 11 +- docs/api/carbonio-ui-preview.previewitem.md | 4 +- .../api/carbonio-ui-preview.previewwrapper.md | 2 +- ...carbonio-ui-preview.previewwrapperprops.md | 4 +- ...ui-preview.videopreviewprops.errorlabel.md | 13 ++ .../carbonio-ui-preview.videopreviewprops.md | 94 +++++++++ ...o-ui-preview.videopreviewprops.mimetype.md | 13 ++ ...rbonio-ui-preview.videopreviewprops.src.md | 13 ++ etc/carbonio-ui-preview.api.md | 17 +- examples/app/package-lock.json | 2 - examples/app/src/App.tsx | 7 + jest.config.ts | 4 +- package-lock.json | 8 - package.json | 2 - src/preview/FocusWithin.tsx | 5 +- src/preview/ImagePreview.tsx | 27 +-- src/preview/PdfPreview.tsx | 22 +-- src/preview/PreviewManager.tsx | 7 +- src/preview/PreviewNavigator.tsx | 25 ++- src/preview/PreviewWrapper.test.tsx | 8 + src/preview/PreviewWrapper.tsx | 13 +- src/preview/VideoPreview.module.css | 29 +++ src/preview/VideoPreview.test.tsx | 179 ++++++++++++++++++ src/preview/VideoPreview.tsx | 134 +++++++++++++ .../{ => hooks}/usePageScrollController.ts | 0 src/preview/{ => hooks}/useZoom.ts | 6 +- 28 files changed, 570 insertions(+), 83 deletions(-) create mode 100644 docs/api/carbonio-ui-preview.videopreviewprops.errorlabel.md create mode 100644 docs/api/carbonio-ui-preview.videopreviewprops.md create mode 100644 docs/api/carbonio-ui-preview.videopreviewprops.mimetype.md create mode 100644 docs/api/carbonio-ui-preview.videopreviewprops.src.md create mode 100644 src/preview/VideoPreview.module.css create mode 100644 src/preview/VideoPreview.test.tsx create mode 100644 src/preview/VideoPreview.tsx rename src/preview/{ => hooks}/usePageScrollController.ts (100%) rename src/preview/{ => hooks}/useZoom.ts (93%) diff --git a/THIRDPARTIES b/THIRDPARTIES index fd27948..5c2dc48 100644 --- a/THIRDPARTIES +++ b/THIRDPARTIES @@ -15,7 +15,6 @@ MIT License (MIT) applies to: @testing-library/react, Copyright (c) 2017 Kent C. Dodds @testing-library/user-event, Copyright (c) 2020 Giorgio Polvara @types/jest, Copyright (c) Microsoft Corporation. -@types/lodash, Copyright (c) Microsoft Corporation. @types/node, Copyright (c) Microsoft Corporation. @types/react-dom, Copyright (c) Microsoft Corporation. @types/react, Copyright (c) Microsoft Corporation. @@ -29,7 +28,6 @@ is-ci, Copyright (c) 2016-2021 Thomas Watson Steen jest-environment-jsdom, Copyright (c) Meta Platforms, Inc. and affiliates. jest-fail-on-console, Copyright (c) 2022 Valentin Hervieu jest, Copyright (c) Meta Platforms, Inc. and affiliates. -lodash, Copyright OpenJS Foundation and other contributors react-dom, Copyright (c) Facebook, Inc. and its affiliates. react-pdf, Copyright (c) 2017–2023 Wojciech Maj react, Copyright (c) Facebook, Inc. and its affiliates. diff --git a/docs/api/carbonio-ui-preview.imagepreview.md b/docs/api/carbonio-ui-preview.imagepreview.md index 0c68912..54bdea2 100644 --- a/docs/api/carbonio-ui-preview.imagepreview.md +++ b/docs/api/carbonio-ui-preview.imagepreview.md @@ -9,5 +9,5 @@ Main component for rendering the preview of an image **Signature:** ```typescript -ImagePreview: import("react").ForwardRefExoticComponent> +ImagePreview: React.ForwardRefExoticComponent> ``` diff --git a/docs/api/carbonio-ui-preview.md b/docs/api/carbonio-ui-preview.md index 3784a3b..9eaa31c 100644 --- a/docs/api/carbonio-ui-preview.md +++ b/docs/api/carbonio-ui-preview.md @@ -107,6 +107,15 @@ Description + + + +[VideoPreviewProps](./carbonio-ui-preview.videopreviewprops.md) + + + + + @@ -174,7 +183,7 @@ The context give access to the functions needed to manage multiple previews. It -Show the preview for either an image or a pdf. This component is just a wrapper on the two specific preview components. +Show the preview for a video, an image or a pdf. This component is just a wrapper on the two specific preview components. diff --git a/docs/api/carbonio-ui-preview.previewitem.md b/docs/api/carbonio-ui-preview.previewitem.md index 3cb3dc4..8cfe167 100644 --- a/docs/api/carbonio-ui-preview.previewitem.md +++ b/docs/api/carbonio-ui-preview.previewitem.md @@ -11,11 +11,13 @@ Define an item for the preview. It can be of type 'image' or 'pdf'. The id is re ```typescript export type PreviewItem = ((MakeOptional, 'onClose'> & { previewType: 'image'; +}) | (MakeOptional, 'onClose'> & { + previewType: 'video'; }) | (MakeOptional, 'onClose'> & { previewType: 'pdf'; })) & { id: string; }; ``` -**References:** [MakeOptional](./carbonio-ui-preview.makeoptional.md), [ImagePreviewProps](./carbonio-ui-preview.imagepreviewprops.md), [PdfPreviewProps](./carbonio-ui-preview.pdfpreviewprops.md) +**References:** [MakeOptional](./carbonio-ui-preview.makeoptional.md), [ImagePreviewProps](./carbonio-ui-preview.imagepreviewprops.md), [VideoPreviewProps](./carbonio-ui-preview.videopreviewprops.md), [PdfPreviewProps](./carbonio-ui-preview.pdfpreviewprops.md) diff --git a/docs/api/carbonio-ui-preview.previewwrapper.md b/docs/api/carbonio-ui-preview.previewwrapper.md index 298bc64..ca63d5d 100644 --- a/docs/api/carbonio-ui-preview.previewwrapper.md +++ b/docs/api/carbonio-ui-preview.previewwrapper.md @@ -4,7 +4,7 @@ ## PreviewWrapper variable -Show the preview for either an image or a pdf. This component is just a wrapper on the two specific preview components. +Show the preview for a video, an image or a pdf. This component is just a wrapper on the two specific preview components. **Signature:** diff --git a/docs/api/carbonio-ui-preview.previewwrapperprops.md b/docs/api/carbonio-ui-preview.previewwrapperprops.md index f072df6..abc8ad7 100644 --- a/docs/api/carbonio-ui-preview.previewwrapperprops.md +++ b/docs/api/carbonio-ui-preview.previewwrapperprops.md @@ -11,7 +11,9 @@ export type PreviewWrapperProps = (ImagePreviewProps & { previewType: 'image'; }) | (PdfPreviewProps & { previewType: 'pdf'; +}) | (VideoPreviewProps & { + previewType: 'video'; }); ``` -**References:** [ImagePreviewProps](./carbonio-ui-preview.imagepreviewprops.md), [PdfPreviewProps](./carbonio-ui-preview.pdfpreviewprops.md) +**References:** [ImagePreviewProps](./carbonio-ui-preview.imagepreviewprops.md), [PdfPreviewProps](./carbonio-ui-preview.pdfpreviewprops.md), [VideoPreviewProps](./carbonio-ui-preview.videopreviewprops.md) diff --git a/docs/api/carbonio-ui-preview.videopreviewprops.errorlabel.md b/docs/api/carbonio-ui-preview.videopreviewprops.errorlabel.md new file mode 100644 index 0000000..75c5ceb --- /dev/null +++ b/docs/api/carbonio-ui-preview.videopreviewprops.errorlabel.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@zextras/carbonio-ui-preview](./carbonio-ui-preview.md) > [VideoPreviewProps](./carbonio-ui-preview.videopreviewprops.md) > [errorLabel](./carbonio-ui-preview.videopreviewprops.errorlabel.md) + +## VideoPreviewProps.errorLabel property + +Label shown when the preview cannot be shown + +**Signature:** + +```typescript +errorLabel?: string; +``` diff --git a/docs/api/carbonio-ui-preview.videopreviewprops.md b/docs/api/carbonio-ui-preview.videopreviewprops.md new file mode 100644 index 0000000..c335f52 --- /dev/null +++ b/docs/api/carbonio-ui-preview.videopreviewprops.md @@ -0,0 +1,94 @@ + + +[Home](./index.md) > [@zextras/carbonio-ui-preview](./carbonio-ui-preview.md) > [VideoPreviewProps](./carbonio-ui-preview.videopreviewprops.md) + +## VideoPreviewProps interface + +**Signature:** + +```typescript +export interface VideoPreviewProps extends Omit +``` +**Extends:** Omit<[PreviewNavigatorProps](./carbonio-ui-preview.previewnavigatorprops.md), 'onOverlayClick'> + +## Properties + + + + + +
+ +Property + + + + +Modifiers + + + + +Type + + + + +Description + + +
+ +[errorLabel?](./carbonio-ui-preview.videopreviewprops.errorlabel.md) + + + + + + + +string + + + + +_(Optional)_ Label shown when the preview cannot be shown + + +
+ +[mimeType?](./carbonio-ui-preview.videopreviewprops.mimetype.md) + + + + + + + +string + + + + +_(Optional)_ File mime type + + +
+ +[src](./carbonio-ui-preview.videopreviewprops.src.md) + + + + + + + +string + + + + +Preview video source + + +
diff --git a/docs/api/carbonio-ui-preview.videopreviewprops.mimetype.md b/docs/api/carbonio-ui-preview.videopreviewprops.mimetype.md new file mode 100644 index 0000000..1bf41d3 --- /dev/null +++ b/docs/api/carbonio-ui-preview.videopreviewprops.mimetype.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@zextras/carbonio-ui-preview](./carbonio-ui-preview.md) > [VideoPreviewProps](./carbonio-ui-preview.videopreviewprops.md) > [mimeType](./carbonio-ui-preview.videopreviewprops.mimetype.md) + +## VideoPreviewProps.mimeType property + +File mime type + +**Signature:** + +```typescript +mimeType?: string; +``` diff --git a/docs/api/carbonio-ui-preview.videopreviewprops.src.md b/docs/api/carbonio-ui-preview.videopreviewprops.src.md new file mode 100644 index 0000000..df3d134 --- /dev/null +++ b/docs/api/carbonio-ui-preview.videopreviewprops.src.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@zextras/carbonio-ui-preview](./carbonio-ui-preview.md) > [VideoPreviewProps](./carbonio-ui-preview.videopreviewprops.md) > [src](./carbonio-ui-preview.videopreviewprops.src.md) + +## VideoPreviewProps.src property + +Preview video source + +**Signature:** + +```typescript +src: string; +``` diff --git a/etc/carbonio-ui-preview.api.md b/etc/carbonio-ui-preview.api.md index 61aed64..6bc48f0 100644 --- a/etc/carbonio-ui-preview.api.md +++ b/etc/carbonio-ui-preview.api.md @@ -4,9 +4,8 @@ ```ts -import { ForwardRefExoticComponent } from 'react'; import * as React_2 from 'react'; -import { RefAttributes } from 'react'; +import { default as React_3 } from 'react'; import { Theme } from '@zextras/carbonio-design-system'; import { TooltipProps } from '@zextras/carbonio-design-system'; @@ -31,7 +30,7 @@ interface HeaderProps { } // @public -export const ImagePreview: ForwardRefExoticComponent>; +export const ImagePreview: React_3.ForwardRefExoticComponent>; // Warning: (ae-forgotten-export) The symbol "PreviewNavigatorProps" needs to be exported by the entry point index.d.ts // @@ -84,10 +83,13 @@ interface PreviewCriteriaAlternativeContentProps { } // Warning: (ae-forgotten-export) The symbol "MakeOptional" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "VideoPreviewProps" needs to be exported by the entry point index.d.ts // // @public export type PreviewItem = ((MakeOptional, 'onClose'> & { previewType: 'image'; +}) | (MakeOptional, 'onClose'> & { + previewType: 'video'; }) | (MakeOptional, 'onClose'> & { previewType: 'pdf'; })) & { @@ -130,9 +132,18 @@ export type PreviewWrapperProps = (ImagePreviewProps & { previewType: 'image'; }) | (PdfPreviewProps & { previewType: 'pdf'; +}) | (VideoPreviewProps & { + previewType: 'video'; }); // @public export const usePreview: () => PreviewManagerContextType; +// @public (undocumented) +interface VideoPreviewProps extends Omit { + errorLabel?: string; + mimeType?: string; + src: string; +} + ``` diff --git a/examples/app/package-lock.json b/examples/app/package-lock.json index 28994b7..c4b6e7a 100644 --- a/examples/app/package-lock.json +++ b/examples/app/package-lock.json @@ -48,7 +48,6 @@ "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", - "@types/lodash": "^4.17.4", "@types/node": "^18.19.34", "@types/react": "^17.0.80", "@types/react-dom": "^17.0.25", @@ -70,7 +69,6 @@ }, "peerDependencies": { "@zextras/carbonio-design-system": ">=1.0.0", - "lodash": "^4.17.21", "react": "^17.0.2", "react-dom": "^17.0.2" } diff --git a/examples/app/src/App.tsx b/examples/app/src/App.tsx index c76b8e2..a85f76f 100644 --- a/examples/app/src/App.tsx +++ b/examples/app/src/App.tsx @@ -30,6 +30,13 @@ const items = [ src: 'https://pdfobject.com/pdf/sample.pdf', filename: 'A sample PDF file', extension: 'pdf' + }, + { + previewType: 'video', + id: 'video-file', + src: 'https://github.com/webrtc/samples/raw/gh-pages/src/video/chrome.mp4', + filename: 'A sample video', + extension: 'mp4' } ] satisfies PreviewItem[]; diff --git a/jest.config.ts b/jest.config.ts index 41b0160..c444b9c 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -21,7 +21,7 @@ const config: Config = { // cacheDirectory: "/tmp/jest_rs", // Automatically clear mock calls, instances, contexts and results before every test - clearMocks: true, + // clearMocks: true, // Indicates whether the coverage information should be collected while executing the test collectCoverage: true, @@ -124,7 +124,7 @@ const config: Config = { // resolver: undefined, // Automatically restore mock state and implementation before every test - // restoreMocks: true, + restoreMocks: true, // The root directory that Jest should scan for tests and modules within // rootDir: undefined, diff --git a/package-lock.json b/package-lock.json index 4608f57..bff1ab4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", - "@types/lodash": "^4.17.4", "@types/node": "^18.19.34", "@types/react": "^17.0.80", "@types/react-dom": "^17.0.25", @@ -49,7 +48,6 @@ }, "peerDependencies": { "@zextras/carbonio-design-system": ">=1.0.0", - "lodash": "^4.17.21", "react": "^17.0.2", "react-dom": "^17.0.2" } @@ -4729,12 +4727,6 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, - "node_modules/@types/lodash": { - "version": "4.17.7", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", - "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", - "dev": true - }, "node_modules/@types/node": { "version": "18.19.34", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.34.tgz", diff --git a/package.json b/package.json index bb73a6d..0e52bda 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", - "@types/lodash": "^4.17.4", "@types/node": "^18.19.34", "@types/react": "^17.0.80", "@types/react-dom": "^17.0.25", @@ -82,7 +81,6 @@ }, "peerDependencies": { "@zextras/carbonio-design-system": ">=1.0.0", - "lodash": "^4.17.21", "react": "^17.0.2", "react-dom": "^17.0.2" }, diff --git a/src/preview/FocusWithin.tsx b/src/preview/FocusWithin.tsx index bf8e058..1a0c7ce 100644 --- a/src/preview/FocusWithin.tsx +++ b/src/preview/FocusWithin.tsx @@ -7,8 +7,6 @@ import { useCallback, useEffect, useRef } from 'react'; import * as React from 'react'; -import { last } from 'lodash'; - import styles from './FocusWithin.module.css'; interface FocusContainerProps { @@ -28,7 +26,8 @@ const FocusWithin = ({ children, returnFocus = true }: FocusContainerProps): Rea const onStartSentinelFocus = useCallback(() => { if (contentRef.current) { - const node = last(contentRef.current.querySelectorAll('[tabindex]')); + const nodeListOf = contentRef.current.querySelectorAll('[tabindex]'); + const node = nodeListOf[nodeListOf.length - 1]; node?.focus(); } }, []); diff --git a/src/preview/ImagePreview.tsx b/src/preview/ImagePreview.tsx index 0f6dffc..fd82128 100644 --- a/src/preview/ImagePreview.tsx +++ b/src/preview/ImagePreview.tsx @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; +import React, { forwardRef, useCallback, useEffect, useState } from 'react'; import { useCombinedRefs } from '@zextras/carbonio-design-system'; @@ -48,30 +48,6 @@ export const ImagePreview = forwardRef(functi }, [src, setComputedSrc]); const previewRef = useCombinedRefs(ref); - const imageRef = useRef(null); - - const eventListener = useCallback<(e: KeyboardEvent) => void>( - (event) => { - if (event.key === 'Escape') { - onClose(event); - } else if (event.key === 'ArrowRight' && onNextPreview) { - onNextPreview(event); - } else if (event.key === 'ArrowLeft' && onPreviousPreview) { - onPreviousPreview(event); - } - }, - [onClose, onNextPreview, onPreviousPreview] - ); - - useEffect(() => { - if (show) { - document.addEventListener('keydown', eventListener); - } - - return (): void => { - document.removeEventListener('keydown', eventListener); - }; - }, [eventListener, show]); const onOverlayClick = useCallback( (event) => { @@ -106,7 +82,6 @@ export const ImagePreview = forwardRef(functi alt={alt ?? filename} src={computedSrc} onError={(error): void => console.error('TODO handle error', error)} - ref={imageRef} className={styles.image} /> diff --git a/src/preview/PdfPreview.tsx b/src/preview/PdfPreview.tsx index fabf525..e234134 100644 --- a/src/preview/PdfPreview.tsx +++ b/src/preview/PdfPreview.tsx @@ -7,13 +7,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import * as React from 'react'; import { useCombinedRefs, getColor, useTheme } from '@zextras/carbonio-design-system'; -import { size as lodashSize, map, noop } from 'lodash'; import type { DocumentProps, PageProps } from 'react-pdf'; import { Document, Page } from 'react-pdf'; import 'react-pdf/dist/Page/TextLayer.css'; import 'react-pdf/dist/Page/AnnotationLayer.css'; import { HeaderAction } from './Header.js'; +import { usePageScrollController } from './hooks/usePageScrollController.js'; +import { useZoom } from './hooks/useZoom.js'; import { Navigator } from './Navigator.js'; import { PageController } from './PageController.js'; import styles from './PdfPreview.module.css'; @@ -22,8 +23,6 @@ import { PreviewCriteriaAlternativeContentProps } from './PreviewCriteriaAlternativeContent.js'; import { PreviewNavigator, PreviewNavigatorProps } from './PreviewNavigator.js'; -import { usePageScrollController } from './usePageScrollController.js'; -import { useZoom } from './useZoom.js'; import { ZoomController } from './ZoomController.js'; import { SCROLL_STEP } from '../constants/index.js'; import { print } from '../utils/utils.js'; @@ -124,7 +123,7 @@ export const PdfPreview = React.forwardRef(func } // ArrayBuffer - File - Blob - data URI string setDocumentFile(src); - return noop; + return (): void => undefined; }, [src, setDocumentFile, forceCache]); const previewRef: React.MutableRefObject = useCombinedRefs(ref); @@ -201,7 +200,7 @@ export const PdfPreview = React.forwardRef(func const pageOnRenderSuccess = useCallback>( (page): void => { registerPageObserver(page); - setPrintReady(lodashSize(pdfPageProxyListRef.current) === numPages); + setPrintReady(Object.values(pdfPageProxyListRef.current).length === numPages); }, [numPages, registerPageObserver] ); @@ -213,7 +212,7 @@ export const PdfPreview = React.forwardRef(func const pageElements = useMemo(() => { if (numPages) { pageRefs.current = []; - return map(new Array(numPages), (el, index) => { + return [...Array(numPages)].map((el, index) => { const pageRef = React.createRef(); pageRefs.current.push(pageRef); return ( @@ -301,15 +300,6 @@ export const PdfPreview = React.forwardRef(func const eventListener = useCallback<(e: KeyboardEvent) => void>( (event) => { switch (event.key) { - case 'Escape': - onClose(event); - break; - case 'ArrowRight': - onNextPreview?.(event); - break; - case 'ArrowLeft': - onPreviousPreview?.(event); - break; case 'Home': if (currentPage > 1) { onPageChange(1); @@ -340,7 +330,7 @@ export const PdfPreview = React.forwardRef(func break; } }, - [currentPage, numPages, onClose, onNextPreview, onPageChange, onPreviousPreview, previewRef] + [currentPage, numPages, onPageChange, previewRef] ); useEffect(() => { diff --git a/src/preview/PreviewManager.tsx b/src/preview/PreviewManager.tsx index b1f25c2..d4e659c 100644 --- a/src/preview/PreviewManager.tsx +++ b/src/preview/PreviewManager.tsx @@ -7,11 +7,10 @@ import { useCallback, createContext, useReducer, useState, useMemo, useContext } from 'react'; import * as React from 'react'; -import { findIndex } from 'lodash'; - import { ImagePreviewProps } from './ImagePreview.js'; import { PdfPreviewProps } from './PdfPreview.js'; import { PreviewWrapper, PreviewWrapperProps } from './PreviewWrapper.js'; +import { VideoPreviewProps } from './VideoPreview.js'; import { type MakeOptional } from '../types/utils.js'; /** @@ -20,6 +19,7 @@ import { type MakeOptional } from '../types/utils.js'; */ export type PreviewItem = ( | (MakeOptional, 'onClose'> & { previewType: 'image' }) + | (MakeOptional, 'onClose'> & { previewType: 'video' }) | (MakeOptional, 'onClose'> & { previewType: 'pdf' }) ) & { id: string; @@ -105,6 +105,7 @@ export const PreviewManager: React.FC = ({ children }) => { return ( { const openPreview = useCallback<(id: string) => void>( (id) => { - const index = findIndex(previews, (preview: PreviewItem) => preview.id === id); + const index = previews.findIndex((preview) => preview.id === id); if (index >= 0) { setOpenArrayIndex(index); } diff --git a/src/preview/PreviewNavigator.tsx b/src/preview/PreviewNavigator.tsx index 0652cb0..d4d9575 100644 --- a/src/preview/PreviewNavigator.tsx +++ b/src/preview/PreviewNavigator.tsx @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import * as React from 'react'; import { IconButton, Portal, Container } from '@zextras/carbonio-design-system'; @@ -67,6 +67,29 @@ export const PreviewNavigator = ({ closeAction, onClose }: React.PropsWithChildren): React.JSX.Element => { + const eventListener = useCallback<(e: KeyboardEvent) => void>( + (event) => { + if (event.key === 'Escape') { + onClose(event); + } else if (event.key === 'ArrowRight' && onNextPreview) { + onNextPreview(event); + } else if (event.key === 'ArrowLeft' && onPreviousPreview) { + onPreviousPreview(event); + } + }, + [onClose, onNextPreview, onPreviousPreview] + ); + + useEffect(() => { + if (show) { + document.addEventListener('keydown', eventListener); + } + + return (): void => { + document.removeEventListener('keydown', eventListener); + }; + }, [eventListener, show]); + const $closeAction = useMemo(() => { if (closeAction) { return { diff --git a/src/preview/PreviewWrapper.test.tsx b/src/preview/PreviewWrapper.test.tsx index 12554d5..a8ab14a 100644 --- a/src/preview/PreviewWrapper.test.tsx +++ b/src/preview/PreviewWrapper.test.tsx @@ -25,4 +25,12 @@ describe('Preview Wrapper', () => { expect(screen.getByRole('img')).toBeVisible(); expect(screen.queryByTestId(SELECTORS.previewContainer)).not.toBeInTheDocument(); }); + + test('Render the video previewer for type video', async () => { + const onClose = jest.fn(); + setup(); + const video = await screen.findByTestId('video'); + expect(video).toBeVisible(); + expect(screen.queryByTestId(SELECTORS.previewContainer)).not.toBeInTheDocument(); + }); }); diff --git a/src/preview/PreviewWrapper.tsx b/src/preview/PreviewWrapper.tsx index 26fae0e..0d8a790 100644 --- a/src/preview/PreviewWrapper.tsx +++ b/src/preview/PreviewWrapper.tsx @@ -7,22 +7,23 @@ import * as React from 'react'; import { ImagePreview, ImagePreviewProps } from './ImagePreview.js'; import { PdfPreview, PdfPreviewProps } from './PdfPreview.js'; +import { VideoPreview, VideoPreviewProps } from './VideoPreview.js'; export type PreviewWrapperProps = | (ImagePreviewProps & { previewType: 'image'; }) - | (PdfPreviewProps & { previewType: 'pdf' }); + | (PdfPreviewProps & { previewType: 'pdf' }) + | (VideoPreviewProps & { previewType: 'video' }); /** - * Show the preview for either an image or a pdf. + * Show the preview for a video, an image or a pdf. * This component is just a wrapper on the two specific preview components. * @param previewType - The type of the preview * @param props - The item to show */ export const PreviewWrapper: React.VFC = ({ previewType, ...props }) => - previewType === 'pdf' ? ( - - ) : ( - + (previewType === 'pdf' && ) || + (previewType === 'image' && ) || ( + ); diff --git a/src/preview/VideoPreview.module.css b/src/preview/VideoPreview.module.css new file mode 100644 index 0000000..feda320 --- /dev/null +++ b/src/preview/VideoPreview.module.css @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2024 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +.video { + max-height: 100%; + max-width: 100%; + min-height: 0; + min-width: 0; + align-self: center; + filter: drop-shadow(0px 5px 14px rgba(0, 0, 0, 0.35)); + border-radius: 0.25rem; +} + +.previewContainer { + display: flex; + max-width: 100%; + max-height: calc(100vh - 2rem * 2); + flex-direction: column; + gap: 0.5rem; + justify-content: center; + align-items: center; + overflow: hidden; + padding: 2rem 1rem; + outline: none; + flex-grow: 1; +} diff --git a/src/preview/VideoPreview.test.tsx b/src/preview/VideoPreview.test.tsx new file mode 100644 index 0000000..f117137 --- /dev/null +++ b/src/preview/VideoPreview.test.tsx @@ -0,0 +1,179 @@ +/* + * SPDX-FileCopyrightText: 2024 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import * as React from 'react'; + +import { fireEvent } from '@testing-library/react'; + +import { VideoPreview, VideoPreviewProps } from './VideoPreview.js'; +import { KEYBOARD_KEY } from '../tests/constants.js'; +import { screen, setup } from '../tests/utils.js'; + +describe('Video Preview', () => { + const cannotBePlayedMessage = 'This video cannot be played.'; + + test('Render a video', () => { + const onClose = jest.fn(); + setup(); + expect(screen.getByTestId('video')).toBeVisible(); + }); + + test('If show is false does not render the video', () => { + const onClose = jest.fn(); + setup(); + expect(screen.queryByTestId('video')).not.toBeInTheDocument(); + }); + + test('Additional data are visible', () => { + const onClose = jest.fn(); + setup( + + ); + expect(screen.getByText(/video file name/i)).toBeVisible(); + expect(screen.getByText(/mp4/i)).toBeVisible(); + expect(screen.getByText(/18MB/i)).toBeVisible(); + }); + + test('Escape key close the preview', async () => { + const onClose = jest.fn(); + const { user } = setup(); + await user.keyboard(KEYBOARD_KEY.ESC); + expect(onClose).toHaveBeenCalled(); + }); + + test('Click on actions calls onClose if event is not stopped by the action itself', async () => { + const onClose = jest.fn>((ev) => { + ev.preventDefault(); + }); + const actions: VideoPreviewProps['actions'] = [ + { + id: 'action1', + icon: 'Activity', + onClick: jest.fn() + }, + { + id: 'action2', + icon: 'People', + onClick: jest.fn((ev: React.MouseEvent | KeyboardEvent) => { + ev.preventDefault(); + }), + disabled: true + } + ]; + + const closeAction: VideoPreviewProps['closeAction'] = { + id: 'closeAction', + icon: 'Close' + }; + const { user } = setup( + + ); + const action1Item = screen.getByRoleWithIcon('button', { icon: 'icon: Activity' }); + const action2Item = screen.getByRoleWithIcon('button', { icon: 'icon: People' }); + const closeActionItem = screen.getByRoleWithIcon('button', { icon: 'icon: Close' }); + expect(action1Item).toBeVisible(); + expect(action2Item).toBeVisible(); + expect(action2Item).toBeDisabled(); + expect(closeActionItem).toBeVisible(); + // click on action 1 is propagated and calls onClose + await user.click(action1Item); + expect(actions[0].onClick).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + // click on action 2 skips the handler of the action since it is disabled and calls onClose + await user.click(action2Item); + expect(actions[1].onClick).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + // click on close action is the onClose itself + await user.click(closeActionItem); + expect(onClose).toHaveBeenCalledTimes(2); + // click on filename is equivalent to a click on the overlay, so onClose is called + await user.click(screen.getByText(/video name/i)); + expect(onClose).toHaveBeenCalledTimes(3); + }); + + it('should not render the video when canPlayType return empty string on mime type (mime type not supported)', () => { + jest.spyOn(HTMLVideoElement.prototype, 'canPlayType').mockReturnValue(''); + + setup( + + ); + + expect(screen.getByText(cannotBePlayedMessage)).toBeVisible(); + expect(screen.queryByTestId('video')).not.toBeInTheDocument(); + }); + + it('should render the video when mime type props is not provided', () => { + setup(); + + expect(screen.getByTestId('video')).toBeVisible(); + expect(screen.queryByText(cannotBePlayedMessage)).not.toBeInTheDocument(); + }); + + it('should render the video when canPlayType return maybe string on mime type', () => { + jest.spyOn(HTMLVideoElement.prototype, 'canPlayType').mockReturnValue('maybe'); + const mimeType = 'video/mp4'; + setup(); + expect(screen.getByTestId('video')).toBeVisible(); + expect(screen.queryByText(cannotBePlayedMessage)).not.toBeInTheDocument(); + }); + + it('should render the video when canPlayType return probably string on mime type', () => { + jest.spyOn(HTMLVideoElement.prototype, 'canPlayType').mockReturnValue('probably'); + const mimeType = 'video/mp4'; + setup(); + expect(screen.getByTestId('video')).toBeVisible(); + expect(screen.queryByText(cannotBePlayedMessage)).not.toBeInTheDocument(); + }); + + it('should render the fail string when video request fails', async () => { + setup(); + fireEvent.error(screen.getByTestId('video')); + expect(await screen.findByText(cannotBePlayedMessage)).toBeVisible(); + }); + + it('should call video play when keyboard space is clicked', async () => { + jest.spyOn(HTMLMediaElement.prototype, 'paused', 'get').mockReturnValue(true); + const playStub = jest.spyOn(window.HTMLVideoElement.prototype, 'play').mockImplementation(); + + const pauseStub = jest.spyOn(window.HTMLVideoElement.prototype, 'pause').mockImplementation(); + + const { user } = setup(); + await user.keyboard(' '); + expect(playStub).toHaveBeenCalled(); + expect(pauseStub).not.toHaveBeenCalled(); + }); + + it('should call video pause when video is not paused and keyboard space is clicked', async () => { + jest.spyOn(HTMLMediaElement.prototype, 'paused', 'get').mockReturnValue(false); + const pauseStub = jest.spyOn(window.HTMLVideoElement.prototype, 'pause').mockImplementation(); + + const playStub = jest.spyOn(window.HTMLVideoElement.prototype, 'play').mockImplementation(); + + const { user } = setup(); + await user.keyboard(' '); + expect(pauseStub).toHaveBeenCalled(); + expect(playStub).not.toHaveBeenCalled(); + }); +}); diff --git a/src/preview/VideoPreview.tsx b/src/preview/VideoPreview.tsx new file mode 100644 index 0000000..273463f --- /dev/null +++ b/src/preview/VideoPreview.tsx @@ -0,0 +1,134 @@ +/* + * SPDX-FileCopyrightText: 2024 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { Text, useCombinedRefs } from '@zextras/carbonio-design-system'; + +import { PreviewNavigator, type PreviewNavigatorProps } from './PreviewNavigator.js'; +import styles from './VideoPreview.module.css'; + +const videoElement = document.createElement('video'); + +export interface VideoPreviewProps extends Omit { + /** Preview video source */ + src: string; + /** File mime type */ + mimeType?: string; + /** Label shown when the preview cannot be shown */ + errorLabel?: string; +} + +/** Main component for rendering the preview of a video */ +export const VideoPreview = forwardRef(function PreviewFn( + { + src, + mimeType, + errorLabel = 'This video cannot be played.', + show, + container, + disablePortal, + extension = '', + filename = '', + size = '', + actions = [], + closeAction, + onClose, + onNextPreview, + onPreviousPreview + }, + ref +) { + const canPlayType = useMemo(() => { + if (mimeType) { + return videoElement.canPlayType(mimeType) !== ''; + } + return true; + }, [mimeType]); + + const [videoFailed, setVideoFailed] = useState(false); + const onVideoError = useCallback(() => { + setVideoFailed(true); + }, []); + + const previewRef = useCombinedRefs(ref); + + const onOverlayClick = useCallback( + (event) => { + event.stopPropagation(); + previewRef.current && + !event.isDefaultPrevented() && + (previewRef.current === event.target || + !previewRef.current.contains(event.target as Node)) && + onClose(event); + }, + [onClose, previewRef] + ); + + const videoRef = useRef(null); + + const eventListener = useCallback<(e: KeyboardEvent) => void>((event) => { + if (event.code === 'Space' && videoRef.current) { + if (videoRef.current.paused) { + videoRef.current.play(); + } else { + videoRef.current.pause(); + } + } + }, []); + + useEffect(() => { + if (show) { + document.addEventListener('keydown', eventListener); + } + + return (): void => { + document.removeEventListener('keydown', eventListener); + }; + }, [eventListener, show]); + + useEffect(() => { + const instance = videoRef.current; + return (): void => { + if (instance) { + instance.pause(); + instance.src = ''; + } + }; + }, []); + + return ( + +
+ {!canPlayType || videoFailed ? ( + {errorLabel} + ) : ( + // eslint-disable-next-line jsx-a11y/media-has-caption +
+
+ ); +}); diff --git a/src/preview/usePageScrollController.ts b/src/preview/hooks/usePageScrollController.ts similarity index 100% rename from src/preview/usePageScrollController.ts rename to src/preview/hooks/usePageScrollController.ts diff --git a/src/preview/useZoom.ts b/src/preview/hooks/useZoom.ts similarity index 93% rename from src/preview/useZoom.ts rename to src/preview/hooks/useZoom.ts index e5bbae6..fdb0f48 100644 --- a/src/preview/useZoom.ts +++ b/src/preview/hooks/useZoom.ts @@ -6,9 +6,7 @@ import { useCallback, useEffect, useState } from 'react'; import * as React from 'react'; -import { findLastIndex } from 'lodash'; - -import { ZOOM_STEPS } from '../constants/index.js'; +import { ZOOM_STEPS } from '../../constants/index.js'; type UseZoomReturnType = { currentZoom: number; @@ -52,7 +50,7 @@ export function useZoom(previewRef: React.RefObject): UseZoomReturnType const decreaseOfOneStep = useCallback(() => { if (decrementable) { - const targetIndex = findLastIndex(ZOOM_STEPS, (step) => step < currentZoom); + const targetIndex = ZOOM_STEPS.findLastIndex((step) => step < currentZoom); if (targetIndex >= 0) { setCurrentZoom(ZOOM_STEPS[targetIndex]); if (targetIndex === 0) {