♻️(frontend) rewrite preview viewers and FilesPreview

Reorganize all viewer components under a viewers/ directory
and rewrite FilesPreview as the single orchestrator at the
preview root. Add ZoomControls shared component for image
and PDF viewers. The new layout centralizes the preview
chrome (header, controls bar, navigation) in FilesPreview
while each viewer only handles its own rendering.
This commit is contained in:
Nathan Vasse
2026-04-13 16:58:28 +02:00
parent a776dbd059
commit 511ca27b30
34 changed files with 621 additions and 494 deletions

View File

@@ -14,8 +14,8 @@ import {
IconSize,
} from "@gouvfr-lasuite/ui-kit";
import { useMemo } from "react";
import { FilePreviewType } from "@/features/ui/preview/files-preview/FilesPreview";
import { getExtensionFromName } from "../../utils/utils";
import { FilePreviewType } from "@/features/ui/preview/FilesPreview";
type ItemIconProps = {
item: Item;

View File

@@ -7,7 +7,7 @@ import {
WorkspaceType,
} from "@/features/drivers/types";
import i18n from "@/features/i18n/initI18n";
import { FilePreviewType } from "@/features/ui/preview/files-preview/FilesPreview";
import { FilePreviewType } from "@/features/ui/preview/FilesPreview";
import { DefaultRoute } from "@/utils/defaultRoutes";
/**
@@ -125,10 +125,7 @@ export const getExtensionFromName = (str: string) => {
const SIZE_UNIT_KEYS = ["B", "KB", "MB", "GB", "TB", "PB"] as const;
export const formatSize = (
size: number,
t?: (key: string) => string,
) => {
export const formatSize = (size: number, t?: (key: string) => string) => {
let convertedSize = size;
let unitIndex = 0;
@@ -138,9 +135,7 @@ export const formatSize = (
}
const unitKey = SIZE_UNIT_KEYS[unitIndex];
const unit = t
? t(`explorer.grid.size_units.${unitKey}`)
: unitKey;
const unit = t ? t(`explorer.grid.size_units.${unitKey}`) : unitKey;
return `${
convertedSize < 10

View File

@@ -1,4 +1,4 @@
.c__modal__backdrop:has(.file-preview-container) {
.c__modal__backdrop:has(.file-preview__container) {
background: var(--c--contextuals--background--semantic--contextual--primary);
backdrop-filter: blur(8px);
.c__modal {
@@ -15,12 +15,12 @@
* have a more opaque backdrop color.
*/
.ReactModalPortal:has(.explorer__search__modal)
~ .ReactModalPortal:has(.file-preview-container)
~ .ReactModalPortal:has(.file-preview__container)
.c__modal__backdrop {
background-color: var(--c--contextuals--background--surface--secondary);
}
.file-preview-container {
.file-preview__container {
width: 100%;
height: 100dvh;
display: flex;
@@ -28,7 +28,7 @@
position: relative;
}
.file-preview-header {
.file-preview__header {
height: 52px;
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
flex-shrink: 0;
@@ -44,7 +44,7 @@
max-width: 100%;
position: relative;
&-left {
&__left {
display: flex;
align-items: center;
gap: var(--c--globals--spacings--3xs);
@@ -52,20 +52,7 @@
min-width: 0; // Allow shrinking
}
&-center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: red;
z-index: 1;
}
&-right {
&__right {
display: flex;
align-items: center;
gap: var(--c--globals--spacings--3xs);
@@ -76,7 +63,7 @@
}
}
.file-preview-title {
.file-preview__title {
display: flex;
align-items: center;
gap: var(--c--globals--spacings--3xs);
@@ -90,7 +77,7 @@
white-space: nowrap;
}
.file-preview-content {
.file-preview__content {
flex: 1;
overflow: hidden;
max-width: 100dvw;
@@ -111,7 +98,7 @@
}
}
.file-preview-main {
.file-preview__main {
flex: 1;
min-width: 0;
transition: all 0.3s ease-in-out;
@@ -126,7 +113,7 @@
height: calc(100vh - 52px);
border-left: 1px solid var(--c--contextuals--border--surface--primary);
transition: transform 0.3s ease-in-out;
transition: transform 0.3s;
z-index: 9998;
overflow-y: auto;
overflow-x: hidden;
@@ -138,8 +125,8 @@
}
}
.file-preview-container.sidebar-open {
.file-preview-main {
.file-preview__container--sidebar-open {
.file-preview__main {
width: calc(100% - 300px);
margin-right: 300px;
}
@@ -175,7 +162,7 @@
}
// Styles for different viewer types
.file-preview-viewer {
.file-preview__viewer {
width: 100%;
height: 100%;
overflow: hidden;
@@ -246,17 +233,58 @@
}
}
//
// -- Navigation buttons --
//
.file-preview__next-button,
.file-preview__previous-button {
position: absolute;
top: calc(50% - 20px);
background-color: white;
transition: transform 0.3s;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--xs, 8px);
border: 1px solid var(--c--contextuals--border--surface--primary);
background: var(--c--contextuals--background--surface--primary);
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
}
.file-preview__next-button {
right: 16px;
}
.file-preview__previous-button {
left: 16px;
}
.file-preview__container--pdf-sidebar-open {
.file-preview__previous-button {
transform: translateX(150px);
}
}
.file-preview__container--sidebar-open {
.file-preview__next-button {
transform: translateX(-300px);
}
}
// Responsive design
@media (max-width: 768px) {
.file-preview-header-content {
.file-preview__header__content {
padding: 0 16px;
}
.file-preview-title {
.file-preview__title {
font-size: 16px;
}
.file-preview-content > * {
.file-preview__content > * {
min-height: calc(100dvh - 84px);
}
@@ -278,10 +306,56 @@
}
}
.file-preview-container.sidebar-open {
.file-preview-main {
.file-preview__container--sidebar-open {
.file-preview__main {
width: 100%;
margin-right: 0;
}
}
.file-preview__next-button,
.file-preview__previous-button {
display: none;
}
}
.file-preview__controls {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
gap: var(--c--globals--spacings--3xs);
padding: var(--c--globals--spacings--3xs);
border-radius: var(--xs, 8px);
border: 1px solid var(--c--contextuals--border--surface--primary);
background: var(--c--contextuals--background--surface--primary);
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
&__separator {
width: 1px;
height: 24px;
border-radius: 1px;
overflow: hidden;
background: var(--c--contextuals--border--surface--primary);
}
&__zoom {
display: flex;
align-items: center;
justify-content: center;
gap: var(--c--globals--spacings--3xs);
&__value {
text-align: center;
min-width: 35px;
font-size: 14px;
font-weight: 600;
color: var(--c--contextuals--content--semantic--neutral--secondary);
cursor: pointer;
padding: 4px 0;
}
}
}

View File

@@ -0,0 +1,419 @@
import {
getMimeCategory,
MimeCategory,
removeFileExtension,
} from "@/features/explorer/utils/mimeTypes";
import { DropdownMenu, Icon, IconType } from "@gouvfr-lasuite/ui-kit";
import {
Button,
ButtonProps,
Modal,
ModalSize,
} from "@gouvfr-lasuite/cunningham-react";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import posthog from "posthog-js";
import dynamic from "next/dynamic";
import clsx from "clsx";
import { VideoPlayer } from "./viewers/video-player/VideoPlayer";
import { AudioPlayer } from "./viewers/audio-player/AudioPlayer";
import { ImageViewer } from "./viewers/image-viewer/ImageViewer";
import { SuspiciousPreview } from "./viewers/suspicious/SuspiciousPreview";
import { NotSupportedPreview } from "./viewers/not-supported/NotSupportedPreview";
import { WopiEditor } from "./viewers/wopi/WopiEditor";
import { OPEN_DELAY } from "./viewers/pdf-preview/pdfConsts";
import { FileIcon } from "@/features/explorer/components/icons/ItemIcon";
const PreviewPdf = dynamic<{
src: string;
onThumbailSidebarOpen?: (isOpen: boolean) => void;
}>(
() =>
import("./viewers/pdf-preview/PdfPreview")
.then((mod) => mod.PdfPreview)
.catch(() => {
const {
OutdatedBrowserPreview,
// eslint-disable-next-line @typescript-eslint/no-require-imports
} = require("./viewers/pdf-preview/OutdatedBrowserPreview");
return OutdatedBrowserPreview;
}),
{
ssr: false,
},
);
export type FilePreviewType = {
id: string;
size: number;
title: string;
mimetype: string;
is_wopi_supported?: boolean;
url_preview: string;
url: string;
};
type FilePreviewData = FilePreviewType & {
category: MimeCategory;
isSuspicious?: boolean;
};
interface FilePreviewProps {
isOpen: boolean;
onClose?: () => void;
title?: string;
files?: FilePreviewType[];
initialIndexFile?: number;
openedFileId?: string;
headerRightContent?: React.ReactNode;
sidebarContent?: React.ReactNode;
onChangeFile?: (file?: FilePreviewType) => void;
handleDownloadFile?: (file?: FilePreviewType) => void;
hideCloseButton?: boolean;
onFileRename?: (file: FilePreviewType, newName: string) => void;
}
export const FilePreview = ({
isOpen,
onClose,
title = "File Preview",
files = [],
initialIndexFile = -1,
openedFileId,
sidebarContent,
headerRightContent,
onChangeFile,
handleDownloadFile,
hideCloseButton,
onFileRename,
}: FilePreviewProps) => {
const { t } = useTranslation();
const [currentIndex, setCurrentIndex] = useState(initialIndexFile);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [classNames, setClassNames] = useState<string[]>([]);
const [pdfThumbnailSidebarOpen, setPdfThumbnailSidebarOpen] = useState(false);
const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false);
const data: FilePreviewData[] = useMemo(() => {
return files?.map((file) => ({
...file,
is_wopi_supported: file.is_wopi_supported ?? false,
category: getMimeCategory(file.mimetype),
}));
}, [files]);
const currentFile: FilePreviewData | undefined =
currentIndex > -1 ? data[currentIndex] : undefined;
const canGoNext = currentIndex < data.length - 1;
const canGoPrevious = currentIndex > 0;
const goToNext = () => {
if (canGoNext) setCurrentIndex(currentIndex + 1);
};
const goToPrevious = () => {
if (canGoPrevious) setCurrentIndex(currentIndex - 1);
};
const handleDownload = async () => {
handleDownloadFile?.(currentFile);
};
const handlePrint = () => {
if (!currentFile) return;
window.open(currentFile.url_preview, "_blank");
};
// Render the appropriate viewer based on file category
const renderViewer = () => {
if (!currentFile) {
return <div>{t("file_preview.unsupported.title")}</div>;
}
if (currentFile.isSuspicious) {
return <SuspiciousPreview handleDownload={handleDownload} />;
}
if (currentFile.is_wopi_supported) {
return <WopiEditor item={currentFile} onFileRename={onFileRename} />;
}
switch (currentFile.category) {
case MimeCategory.IMAGE:
if (currentFile.mimetype.includes("heic")) {
return (
<NotSupportedPreview
title={t("file_preview.unsupported.heic_title")}
file={currentFile}
onDownload={handleDownload}
/>
);
}
return (
<ImageViewer
src={currentFile.url_preview}
alt={currentFile.title}
className="file-preview__viewer"
/>
);
case MimeCategory.VIDEO:
return (
<div className="video-preview-viewer-container">
<div className="video-preview-viewer">
<VideoPlayer
src={currentFile.url_preview}
className="file-preview__viewer"
controls={true}
/>
</div>
</div>
);
case MimeCategory.AUDIO:
return (
<div className="video-preview-viewer-container">
<div className="video-preview-viewer">
<AudioPlayer
src={currentFile.url_preview}
title={currentFile.title}
className="file-preview__viewer"
/>
</div>
</div>
);
case MimeCategory.PDF:
return (
<PreviewPdf
src={currentFile.url_preview}
onThumbailSidebarOpen={(isOpen) => {
setPdfThumbnailSidebarOpen(isOpen);
}}
/>
);
default:
return (
<NotSupportedPreview file={currentFile} onDownload={handleDownload} />
);
}
};
// Add a specific class name to the container when the PDF thumbnail sidebar is open.
// So the navigation buttons can move in sync.
useEffect(() => {
const className = "file-preview__container--pdf-sidebar-open";
const isPdf = currentFile?.category === MimeCategory.PDF;
if (pdfThumbnailSidebarOpen && isPdf) {
// The timeout is set so that the thumbnail sidebar and the button move in sync.
setTimeout(() => {
setClassNames((prev) => {
if (prev.includes(className)) {
return prev;
}
return [...prev, className];
});
}, OPEN_DELAY);
} else {
setClassNames((prev) => {
return prev.filter((className) => className !== className);
});
}
// When switching from a PDF to a non-PDF file, we mark the thumbnail sidebar as closed.
if (pdfThumbnailSidebarOpen && !isPdf) {
setPdfThumbnailSidebarOpen(false);
}
}, [pdfThumbnailSidebarOpen, currentFile]);
useEffect(() => {
if (openedFileId) {
const index = data.findIndex((file) => file.id === openedFileId);
const newIndex = index > -1 ? index : -1;
setCurrentIndex(newIndex);
} else {
setCurrentIndex(-1);
}
}, [openedFileId]);
useEffect(() => {
onChangeFile?.(currentFile);
if (currentFile) {
posthog.capture("file_preview_opened", {
id: currentFile.id,
size: currentFile.size,
mimetype: currentFile.mimetype,
});
}
}, [currentFile]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Prevent navigation if the target is a player timeline.
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
return;
}
if (e.key === "ArrowLeft") {
e.preventDefault();
goToPrevious();
} else if (e.key === "ArrowRight") {
e.preventDefault();
goToNext();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [currentIndex, data.length, currentFile?.category]);
useEffect(() => {
if (!isOpen || !currentFile) return;
const previousTitle = document.title;
document.title = `${currentFile.title} - ${t("app_title")}`;
return () => {
document.title = previousTitle;
};
}, [isOpen, currentFile, t]);
if (!isOpen || !currentFile) {
return null;
}
return (
<Modal
isOpen={isOpen}
onClose={() => onClose?.()}
size={ModalSize.FULL}
hideCloseButton={true}
>
<div data-testid="file-preview">
<div
className={clsx(
"file-preview__container",
isSidebarOpen && "file-preview__container--sidebar-open",
classNames,
)}
>
<div className="file-preview__header">
<div className="file-preview__header__content">
<div className="file-preview__header__content__left">
{!hideCloseButton && (
<Button
variant="tertiary"
size="small"
onClick={onClose}
icon={<Icon name="close" />}
/>
)}
<div className="file-preview__title">
<FileIcon file={currentFile} type="mini" size="small" />
<h1 className="file-preview__title">
{removeFileExtension(currentFile?.title || title)}
</h1>
</div>
</div>
<div className="file-preview__header__content__right">
{headerRightContent}
{handleDownloadFile && (
<Button
variant="tertiary"
onClick={handleDownload}
icon={
<Icon type={IconType.OUTLINED} name={"file_download"} />
}
/>
)}
<Button
variant="tertiary"
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
icon={<Icon name={"info_outline"} />}
/>
{(currentFile?.category === MimeCategory.PDF ||
currentFile?.category === MimeCategory.IMAGE) && (
<DropdownMenu
options={[
...(handleDownloadFile
? [
{
icon: (
<Icon
type={IconType.OUTLINED}
name="file_download"
/>
),
label: t("file_preview.actions.download"),
value: "download",
callback: handleDownload,
},
]
: []),
{
icon: <Icon name="print" />,
label: t("file_preview.actions.print"),
value: "print",
callback: handlePrint,
},
]}
isOpen={isActionsMenuOpen}
onOpenChange={setIsActionsMenuOpen}
>
<Button
variant="tertiary"
onClick={() => setIsActionsMenuOpen(!isActionsMenuOpen)}
icon={<Icon name="more_vert" />}
/>
</DropdownMenu>
)}
</div>
</div>
</div>
<div className="file-preview__content">
<div className="file-preview__main">
{renderViewer()}
<FilePreviewPreviousButton
onClick={goToPrevious}
disabled={!canGoPrevious}
/>
<FilePreviewNextButton onClick={goToNext} disabled={!canGoNext} />
</div>
<div
className={`file-preview-sidebar ${isSidebarOpen ? "open" : ""}`}
>
{sidebarContent}
</div>
</div>
</div>
</div>
</Modal>
);
};
const FilePreviewNextButton = (props: Partial<ButtonProps>) => {
return (
<div className="file-preview__next-button">
<Button
{...props}
icon={<Icon name="arrow_forward" />}
color="brand"
variant="tertiary"
size="small"
/>
</div>
);
};
const FilePreviewPreviousButton = (props: Partial<ButtonProps>) => {
return (
<div className="file-preview__previous-button">
<Button
{...props}
icon={<Icon name="arrow_back" />}
color="brand"
variant="tertiary"
size="small"
/>
</div>
);
};

View File

@@ -0,0 +1,42 @@
import { ResetZoomIcon } from "@/features/ui/components/icon/ResetZoomIcon";
import { ZoomMinusIcon } from "@/features/ui/components/icon/ZoomMinusIcon";
import { ZoomPlusIcon } from "@/features/ui/components/icon/ZoomPlusIcon";
import { Button } from "@gouvfr-lasuite/cunningham-react";
interface ZoomControlsProps {
zoomOut: () => void;
zoomIn: () => void;
resetView: () => void;
}
export const ZoomControls = ({
zoomOut,
zoomIn,
resetView,
}: ZoomControlsProps) => {
return (
<div className="file-preview__controls__zoom">
<Button
variant="tertiary"
color="neutral"
size="small"
onClick={zoomOut}
icon={<ZoomMinusIcon />}
/>
<Button
variant="tertiary"
color="neutral"
size="small"
onClick={resetView}
icon={<ResetZoomIcon />}
/>
<Button
variant="tertiary"
color="neutral"
size="small"
onClick={zoomIn}
icon={<ZoomPlusIcon />}
/>
</div>
);
};

View File

@@ -1,5 +1,5 @@
import { Item, ItemType } from "@/features/drivers/types";
import { FilePreview, FilePreviewType } from "../files-preview/FilesPreview";
import { FilePreview, FilePreviewType } from "../FilesPreview";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { itemToPreviewFile } from "@/features/explorer/utils/utils";

View File

@@ -1,302 +0,0 @@
import {
getMimeCategory,
MimeCategory,
removeFileExtension,
} from "@/features/explorer/utils/mimeTypes";
import { Icon, IconType } from "@gouvfr-lasuite/ui-kit";
import { Button, Modal, ModalSize } from "@gouvfr-lasuite/cunningham-react";
import React, { useEffect, useMemo, useState } from "react";
import { ImageViewer } from "../image-viewer/ImageViewer";
import { VideoPlayer } from "../video-player/VideoPlayer";
import { AudioPlayer } from "../audio-player/AudioPlayer";
import { NotSupportedPreview } from "../not-supported/NotSupportedPreview";
import { FileIcon } from "@/features/explorer/components/icons/ItemIcon";
import { useTranslation } from "react-i18next";
import { SuspiciousPreview } from "../suspicious/SuspiciousPreview";
import { WopiEditor } from "../wopi/WopiEditor";
import posthog from "posthog-js";
import dynamic from "next/dynamic";
const PreviewPdf = dynamic<{ src: string }>(
() =>
import("../pdf-preview/PreviewPdf")
.then((mod) => mod.PreviewPdf)
.catch(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { OutdatedBrowserPreview } = require("../pdf-preview/OutdatedBrowserPreview");
return OutdatedBrowserPreview;
}),
{
ssr: false,
},
);
export type FilePreviewType = {
id: string;
size: number;
title: string;
mimetype: string;
is_wopi_supported?: boolean;
url_preview: string;
url: string;
};
type FilePreviewData = FilePreviewType & {
category: MimeCategory;
isSuspicious?: boolean;
};
interface FilePreviewProps {
isOpen: boolean;
onClose?: () => void;
title?: string;
files?: FilePreviewType[];
initialIndexFile?: number;
openedFileId?: string;
headerRightContent?: React.ReactNode;
sidebarContent?: React.ReactNode;
onChangeFile?: (file?: FilePreviewType) => void;
handleDownloadFile?: (file?: FilePreviewType) => void;
hideCloseButton?: boolean;
hideNav?: boolean;
onFileRename?: (file: FilePreviewType, newName: string) => void;
}
export const FilePreview = ({
isOpen,
onClose,
title = "File Preview",
files = [],
initialIndexFile = -1,
openedFileId,
sidebarContent,
headerRightContent,
onChangeFile,
handleDownloadFile,
hideCloseButton,
hideNav,
onFileRename,
}: FilePreviewProps) => {
const { t } = useTranslation();
const [currentIndex, setCurrentIndex] = useState(initialIndexFile);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const data: FilePreviewData[] = useMemo(() => {
return files?.map((file) => ({
...file,
is_wopi_supported: file.is_wopi_supported ?? false,
category: getMimeCategory(file.mimetype),
}));
}, [files]);
const currentFile: FilePreviewData | undefined =
currentIndex > -1 ? data[currentIndex] : undefined;
const handleDownload = async () => {
handleDownloadFile?.(currentFile);
};
// Render the appropriate viewer based on file category
const renderViewer = () => {
if (!currentFile) {
return <div>{t("file_preview.unsupported.title")}</div>;
}
if (currentFile.isSuspicious) {
return <SuspiciousPreview handleDownload={handleDownload} />;
}
if (currentFile.is_wopi_supported) {
return <WopiEditor item={currentFile} onFileRename={onFileRename} />;
}
switch (currentFile.category) {
case MimeCategory.IMAGE:
if (currentFile.mimetype.includes("heic")) {
return (
<NotSupportedPreview
title={t("file_preview.unsupported.heic_title")}
file={currentFile}
onDownload={handleDownload}
/>
);
}
return (
<ImageViewer
src={currentFile.url_preview}
alt={currentFile.title}
className="file-preview-viewer"
/>
);
case MimeCategory.VIDEO:
return (
<div className="video-preview-viewer-container">
<div className="video-preview-viewer">
<VideoPlayer
src={currentFile.url_preview}
className="file-preview-viewer"
controls={true}
/>
</div>
</div>
);
case MimeCategory.AUDIO:
return (
<div className="video-preview-viewer-container">
<div className="video-preview-viewer">
<AudioPlayer
src={currentFile.url_preview}
title={currentFile.title}
className="file-preview-viewer"
/>
</div>
</div>
);
case MimeCategory.PDF:
return <PreviewPdf src={currentFile.url_preview} />;
default:
return (
<NotSupportedPreview file={currentFile} onDownload={handleDownload} />
);
}
};
useEffect(() => {
if (openedFileId) {
const index = data.findIndex((file) => file.id === openedFileId);
const newIndex = index > -1 ? index : -1;
setCurrentIndex(newIndex);
} else {
setCurrentIndex(-1);
}
}, [openedFileId]);
useEffect(() => {
onChangeFile?.(currentFile);
if (currentFile) {
posthog.capture("file_preview_opened", {
id: currentFile.id,
size: currentFile.size,
mimetype: currentFile.mimetype,
});
}
}, [currentFile]);
if (!isOpen || !currentFile) {
return null;
}
return (
<Modal
isOpen={isOpen}
onClose={() => onClose?.()}
size={ModalSize.FULL}
hideCloseButton={true}
>
<div data-testid="file-preview">
<div
className={`file-preview-container ${
isSidebarOpen ? "sidebar-open" : ""
}`}
>
<div className="file-preview-header">
<div className="file-preview-header__content">
<div className="file-preview-header__content-left">
{!hideCloseButton && (
<Button
variant="tertiary"
size="small"
onClick={onClose}
icon={<Icon name="close" />}
/>
)}
<div className="file-preview-title">
<FileIcon file={currentFile} type="mini" size="small" />
<h1 className="file-preview-title">
{removeFileExtension(currentFile?.title || title)}
</h1>
</div>
</div>
<div className="file-preview-header__content-center">
{!hideNav && (
<FilePreviewNav
currentIndex={currentIndex}
totalFiles={data.length}
onPrevious={() => setCurrentIndex(currentIndex - 1)}
onNext={() => setCurrentIndex(currentIndex + 1)}
/>
)}
</div>
<div className="file-preview-header__content-right">
{headerRightContent}
{handleDownloadFile && (
<Button
variant="tertiary"
onClick={handleDownload}
icon={
<Icon type={IconType.OUTLINED} name={"file_download"} />
}
/>
)}
<Button
variant="tertiary"
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
icon={<Icon name={"info_outline"} />}
/>
</div>
</div>
</div>
<div className="file-preview-content">
<div className="file-preview-main">{renderViewer()}</div>
<div
className={`file-preview-sidebar ${isSidebarOpen ? "open" : ""}`}
>
{sidebarContent}
</div>
</div>
</div>
</div>
</Modal>
);
};
interface FilePreviewNavProps {
currentIndex: number;
totalFiles: number;
onPrevious: () => void;
onNext: () => void;
}
export const FilePreviewNav: React.FC<FilePreviewNavProps> = ({
currentIndex,
totalFiles,
onPrevious,
onNext,
}) => {
if (totalFiles === 1) {
return null;
}
return (
<div className="file-preview-nav" data-testid="file-preview-nav">
<Button
variant="tertiary"
onClick={onPrevious}
disabled={currentIndex === 0}
icon={<Icon name="arrow_back" />}
/>
<span className="file-preview-nav__count">
{currentIndex + 1} / {totalFiles}
</span>
<Button
variant="tertiary"
onClick={onNext}
disabled={currentIndex === totalFiles - 1}
icon={<Icon name="arrow_forward" />}
/>
</div>
);
};

View File

@@ -1,12 +1,12 @@
@use "./audio-player/audio-player.scss";
@use "./files-preview/files-preview.scss";
@use "./image-viewer/ImageViewer.scss";
@use "./video-player/VideoPlayer.scss";
@use "./FilesPreview.scss";
@use "./components/duration-bar/DurationBar.scss";
@use "./components/volume-bar/VolumeBar.scss";
@use "./components/controls/PreviewControls.scss";
@use "./not-supported/NotSupportedPreview.scss";
@use "./suspicious/SuspiciousPreview.scss";
@use "./wopi/WopiEditor.scss";
@use "./error/ErrorPreview.scss";
@use "./pdf-preview/PreviewPdf.scss";
@use "./viewers/not-supported/NotSupportedPreview.scss";
@use "./viewers/suspicious/SuspiciousPreview.scss";
@use "./viewers/wopi/WopiEditor.scss";
@use "./viewers/error/ErrorPreview.scss";
@use "./viewers/audio-player/AudioPlayer.scss";
@use "./viewers/image-viewer/ImageViewer.scss";
@use "./viewers/video-player/VideoPlayer.scss";
@use "./viewers/pdf-preview/PdfPreview.scss";

View File

@@ -2,8 +2,8 @@
import React, { useRef, useState, useEffect, useCallback } from "react";
import { clsx } from "clsx";
import { ProgressBar } from "../components/duration-bar/DurationBar";
import { PreviewControls } from "../components/controls/PreviewControls";
import { ProgressBar } from "../../components/duration-bar/DurationBar";
import { PlayerPreviewControls } from "../../components/controls/PreviewControls";
interface AudioPlayerProps {
src: string;
@@ -168,7 +168,7 @@ export const AudioPlayer: React.FC<AudioPlayerProps> = ({
handleSeek={handleSeek}
/>
<PreviewControls
<PlayerPreviewControls
togglePlay={togglePlayPause}
isPlaying={isPlaying}
rewind10Seconds={handleRewind10Seconds}

View File

@@ -1,11 +1,11 @@
import { useTranslation } from "react-i18next";
import { FilePreviewType } from "../files-preview/FilesPreview";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { Icon, IconType } from "@gouvfr-lasuite/ui-kit";
import { FileIcon } from "@/features/explorer/components/icons/ItemIcon";
import { downloadFile } from "@/features/items/utils";
import { useCallback } from "react";
import { FilePreviewType } from "../../FilesPreview";
interface ErrorPreviewProps {
file: FilePreviewType;

View File

@@ -6,14 +6,6 @@
overflow: hidden;
position: relative;
&__controls {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
&__container {
flex: 1;
position: relative;

View File

@@ -1,10 +1,5 @@
"use client";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import React, { useState, useRef, useCallback, useEffect } from "react";
import { ResetZoomIcon } from "../../components/icon/ResetZoomIcon";
import { ZoomMinusIcon } from "../../components/icon/ZoomMinusIcon";
import { ZoomPlusIcon } from "../../components/icon/ZoomPlusIcon";
import { ZoomControls } from "../../components/ZoomControls/ZoomControls";
interface ImageViewerProps {
src: string;
@@ -405,41 +400,9 @@ export const ImageViewer: React.FC<ImageViewerProps> = ({
)}
</div>
<div className="image-viewer__controls">
<ZoomControl
zoomOut={zoomOut}
zoomIn={zoomIn}
zoom={zoom}
resetView={resetView}
/>
<div className="file-preview__controls">
<ZoomControls zoomOut={zoomOut} zoomIn={zoomIn} resetView={resetView} />
</div>
</div>
);
};
interface ZoomControlProps {
zoomOut: () => void;
zoomIn: () => void;
zoom: number;
resetView: () => void;
}
export const ZoomControl = ({
zoomOut,
zoomIn,
resetView,
}: ZoomControlProps) => {
return (
<div className="zoom-control">
<Button variant="tertiary" color="neutral" onClick={zoomOut}>
<ZoomMinusIcon />
</Button>
<Button variant="tertiary" color="neutral" onClick={resetView}>
<ResetZoomIcon />
</Button>
<Button variant="tertiary" color="neutral" onClick={zoomIn}>
<ZoomPlusIcon />
</Button>
</div>
);
};

View File

@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { FilePreviewType } from "../files-preview/FilesPreview";
import { FilePreviewType } from "../../FilesPreview";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { Icon, IconType } from "@gouvfr-lasuite/ui-kit";

View File

@@ -1,8 +1,6 @@
import { Button, Input } from "@gouvfr-lasuite/cunningham-react";
import { LeftSidebarIcon } from "../../components/icon/LeftSidebarIcon";
import { ZoomOut } from "../../components/icon/ZoomOut";
import { ZoomIn } from "../../components/icon/ZoomIn";
import { ZoomReset } from "../../components/icon/ZoomReset";
import { LeftSidebarIcon } from "../../../components/icon/LeftSidebarIcon";
import { ZoomControls } from "../../components/ZoomControls/ZoomControls";
interface PdfControlsProps {
numPages: number;
@@ -29,7 +27,7 @@ export function PdfControls({
onZoomOut,
}: PdfControlsProps) {
return (
<div className="pdf-preview__controls">
<div className="file-preview__controls">
<Button
variant="tertiary"
color="neutral"
@@ -38,7 +36,7 @@ export function PdfControls({
size="small"
icon={<LeftSidebarIcon />}
/>
<div className="controls-vertical-separator" />
<div className="file-preview__controls__separator" />
<div className="pdf-preview__page-nav">
<div className="pdf-preview__page-indicator">
<Input
@@ -53,30 +51,12 @@ export function PdfControls({
<span className="pdf-preview__page-total">/ {numPages}</span>
</div>
</div>
<div className="controls-vertical-separator" />
<div className="pdf-preview__zoom-controls">
<Button
variant="tertiary"
color="neutral"
onClick={onZoomOut}
icon={<ZoomOut />}
size="small"
/>
<Button
variant="tertiary"
color="neutral"
onClick={onZoomReset}
icon={<ZoomReset />}
size="small"
/>
<Button
variant="tertiary"
color="neutral"
onClick={onZoomIn}
icon={<ZoomIn />}
size="small"
/>
</div>
<div className="file-preview__controls__separator" />
<ZoomControls
zoomOut={onZoomOut}
zoomIn={onZoomIn}
resetView={onZoomReset}
/>
</div>
);
}

View File

@@ -158,23 +158,6 @@
box-shadow: 0 3.649px 14.597px 0 rgba(0, 0, 0, 0.1);
}
&__controls {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
gap: var(--c--globals--spacings--xs);
border-radius: var(--c--globals--spacings--3xs);
padding: var(--c--globals--spacings--3xs);
border-radius: var(--xs, 8px);
border: 1px solid var(--c--contextuals--border--surface--primary);
background: var(--c--contextuals--background--surface--primary);
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
}
&__zoom-controls {
display: flex;
align-items: center;
@@ -185,6 +168,7 @@
display: flex;
align-items: center;
gap: var(--c--globals--spacings--3xs);
padding: 0 4px;
}
&__page-indicator {
@@ -194,7 +178,6 @@
font-size: 14px;
font-weight: 600;
color: var(--c--contextuals--content--semantic--neutral--secondary);
padding-right: 4px;
.c__field {
width: auto;
@@ -230,31 +213,6 @@
}
}
.controls-vertical-separator {
width: 1px;
height: 24px;
border-radius: 1px;
overflow: hidden;
background: var(--c--contextuals--border--surface--primary);
}
.zoom-control {
display: flex;
align-items: center;
justify-content: center;
gap: var(--c--globals--spacings--3xs);
&__value {
text-align: center;
min-width: 35px;
font-size: 14px;
font-weight: 600;
color: var(--c--contextuals--content--semantic--neutral--secondary);
cursor: pointer;
padding: 4px 0;
}
}
@keyframes pdf-skeleton-pulse {
0%,
100% {

View File

@@ -20,7 +20,13 @@ import { pdfOptions } from "./pdfOptions";
// Configure PDF.js worker source for PDF loading
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.mjs";
export function PreviewPdf({ src }: { src: string }) {
export function PdfPreview({
src,
onThumbailSidebarOpen,
}: {
src: string;
onThumbailSidebarOpen?: (isOpen: boolean) => void;
}) {
const { t } = useTranslation();
const [numPages, setNumPages] = useState<number>(1);
const [documentError, setDocumentError] = useState<
@@ -43,7 +49,10 @@ export function PreviewPdf({ src }: { src: string }) {
setZoom(1);
}, []);
const toggleSidebar = useCallback(() => {
setIsSidebarOpen((prev) => !prev);
setIsSidebarOpen((prev) => {
onThumbailSidebarOpen?.(!prev);
return !prev;
});
}, []);
const scrollToPage = useCallback((page: number) => {

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { Thumbnail } from "react-pdf";
import { AutoSizer, List } from "react-virtualized";
import type { ListRowRenderer } from "react-virtualized";
import { OPEN_DELAY, ROW_HEIGHT, THUMBNAIL_GAP, TRANSITION_DELAY } from "./pdfConsts";
interface PdfThumbnailSidebarProps {
numPages: number;
@@ -10,11 +11,6 @@ interface PdfThumbnailSidebarProps {
isOpen: boolean;
}
const TRANSITION_DELAY = 300;
const THUMBNAIL_HEIGHT = 178;
const THUMBNAIL_GAP = 12;
const ROW_HEIGHT = THUMBNAIL_HEIGHT + THUMBNAIL_GAP;
// Two-phase mount/unmount to allow CSS transitions to play out:
// Opening: mount immediately (unmount=false), then defer isOpenProxy=true
// so the DOM is present before the "open" class triggers the transition.
@@ -30,7 +26,7 @@ export function PdfThumbnailSidebar(props: PdfThumbnailSidebarProps) {
let timer: ReturnType<typeof setTimeout>;
if (props.isOpen) {
setUnmount(false);
timer = setTimeout(() => setIsOpenProxy(true), 100);
timer = setTimeout(() => setIsOpenProxy(true), OPEN_DELAY);
} else {
setIsOpenProxy(false);
// The 1.1 is to allow for the transition to finish.

View File

@@ -0,0 +1,5 @@
export const TRANSITION_DELAY = 300;
export const THUMBNAIL_HEIGHT = 178;
export const THUMBNAIL_GAP = 12;
export const ROW_HEIGHT = THUMBNAIL_HEIGHT + THUMBNAIL_GAP;
export const OPEN_DELAY = 100;

View File

@@ -3,8 +3,8 @@
import React, { useRef, useState, useEffect, useCallback } from "react";
import clsx from "clsx";
import { Icon } from "@gouvfr-lasuite/ui-kit";
import { ProgressBar } from "../components/duration-bar/DurationBar";
import { PreviewControls } from "../components/controls/PreviewControls";
import { ProgressBar } from "../../components/duration-bar/DurationBar";
import { PlayerPreviewControls } from "../../components/controls/PreviewControls";
interface VideoPlayerProps {
src: string;
@@ -269,16 +269,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
{controls && !isFullscreen && (
<div className="video-player__controls">
{/* Progress bar */}
<ProgressBar
duration={duration}
currentTime={currentTime}
handleSeek={handleSeek}
/>
{/* Control buttons */}
<PreviewControls
<PlayerPreviewControls
togglePlay={togglePlay}
isPlaying={isPlaying}
rewind10Seconds={rewind10Seconds}

View File

@@ -2,8 +2,8 @@ import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getDriver } from "@/features/config/Config";
import { FilePreviewType } from "../files-preview/FilesPreview";
import { ErrorPreview } from "../error/ErrorPreview";
import { FilePreviewType } from "../../FilesPreview";
interface WopiEditorProps {
item: FilePreviewType;