Extract document viewer toolbar component
Keeps DocumentViewer focused on layout and preview state, and gives the page/zoom/copy/download chrome its own home. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -24,10 +24,11 @@ import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton";
|
||||
|
||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||
|
||||
import { documentViewer } from "./_components/variants";
|
||||
import { documentViewer, documentViewerToolbar } from "./_components/variants";
|
||||
|
||||
export function DocumentViewerPageSkeleton() {
|
||||
const slots = documentViewer();
|
||||
const toolbar = documentViewerToolbar();
|
||||
|
||||
return (
|
||||
<div className={slots.root()}>
|
||||
@@ -35,9 +36,9 @@ export function DocumentViewerPageSkeleton() {
|
||||
<div className={slots.header()}>
|
||||
<TextSkeleton size={1} className="w-20" />
|
||||
<HeadingSkeleton size={7} className="w-80" />
|
||||
<div className={slots.toolbar()}>
|
||||
<div className={toolbar.root()}>
|
||||
<ButtonSkeleton size={2} />
|
||||
<div className={slots.actions()}>
|
||||
<div className={toolbar.actions()}>
|
||||
<ButtonSkeleton size={2} />
|
||||
<ButtonSkeleton size={2} />
|
||||
</div>
|
||||
|
||||
@@ -18,22 +18,9 @@
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { Toast } from "@base-ui/react/toast";
|
||||
import {
|
||||
CaretLeftIcon,
|
||||
CaretRightIcon,
|
||||
DownloadSimpleIcon,
|
||||
LinkSimpleIcon,
|
||||
MagnifyingGlassMinusIcon,
|
||||
MagnifyingGlassPlusIcon,
|
||||
SpinnerGapIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||
import { CaretLeftIcon, SpinnerGapIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@probo/ui/src/v2/Button/Link";
|
||||
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
|
||||
import { Separator } from "@probo/ui/src/v2/Separator/Separator";
|
||||
import { Heading } from "@probo/ui/src/v2/typography/Heading";
|
||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -44,13 +31,11 @@ import { dataUriMimeType, downloadDataUri } from "../_lib/dataUri";
|
||||
|
||||
import { DocumentDownloadFallback } from "./DocumentDownloadFallback";
|
||||
import { DocumentLocked } from "./DocumentLocked";
|
||||
import { DocumentViewerToolbar } from "./DocumentViewerToolbar";
|
||||
import type { PdfPreviewHandle } from "./PdfPreview";
|
||||
import { PdfPreview } from "./PdfPreview";
|
||||
import { documentViewer } from "./variants";
|
||||
|
||||
const MIN_SCALE = 0.5;
|
||||
const MAX_SCALE = 3;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
@@ -83,7 +68,6 @@ interface DocumentViewerProps {
|
||||
// place of the preview.
|
||||
export function DocumentViewer({ title, dataUri = null, downloadName = title, locked }: DocumentViewerProps) {
|
||||
const { t } = useTranslation("documents");
|
||||
const toast = Toast.useToastManager();
|
||||
const localizedPath = useLocalizedPath();
|
||||
|
||||
const pdfRef = useRef<PdfPreviewHandle>(null);
|
||||
@@ -102,13 +86,6 @@ export function DocumentViewer({ title, dataUri = null, downloadName = title, lo
|
||||
setCurrentPage(next);
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(window.location.href).then(
|
||||
() => toast.add({ title: t("viewer.linkCopied"), type: "success" }),
|
||||
() => {},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (dataUri) {
|
||||
downloadDataUri(dataUri, downloadName);
|
||||
@@ -128,81 +105,16 @@ export function DocumentViewer({ title, dataUri = null, downloadName = title, lo
|
||||
{title}
|
||||
</Heading>
|
||||
{!isLocked && (
|
||||
<div className={slots.toolbar()}>
|
||||
<div className={slots.toolbarStart()}>
|
||||
{isPdf && (
|
||||
<>
|
||||
<div className={slots.controls()}>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
aria-label={t("common.previousPage")}
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => movePage(-1)}
|
||||
>
|
||||
<CaretLeftIcon />
|
||||
</IconButton>
|
||||
<Text size={2} color="neutral">
|
||||
{t("common.pageOf", { current: currentPage, total: numPages })}
|
||||
</Text>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
aria-label={t("common.nextPage")}
|
||||
disabled={currentPage >= numPages}
|
||||
onClick={() => movePage(1)}
|
||||
>
|
||||
<CaretRightIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Separator orientation="vertical" className={slots.separator()} />
|
||||
<div className={slots.controls()}>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
aria-label={t("common.zoomOut")}
|
||||
onClick={() => setScale(value => clamp(value * 0.8, MIN_SCALE, MAX_SCALE))}
|
||||
>
|
||||
<MagnifyingGlassMinusIcon />
|
||||
</IconButton>
|
||||
<Text size={2} color="neutral">
|
||||
{`${Math.round(scale * 100)}%`}
|
||||
</Text>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
aria-label={t("common.zoomIn")}
|
||||
onClick={() => setScale(value => clamp(value * 1.25, MIN_SCALE, MAX_SCALE))}
|
||||
>
|
||||
<MagnifyingGlassPlusIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={slots.actions()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
iconStart={<LinkSimpleIcon />}
|
||||
onClick={handleCopyLink}
|
||||
aria-label={t("viewer.copyLink")}
|
||||
>
|
||||
<span className={slots.actionLabel()}>{t("viewer.copyLink")}</span>
|
||||
</Button>
|
||||
<Separator orientation="vertical" className={slots.separator()} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
iconStart={<DownloadSimpleIcon />}
|
||||
disabled={dataUri == null}
|
||||
onClick={handleDownload}
|
||||
aria-label={t("viewer.download")}
|
||||
>
|
||||
<span className={slots.actionLabel()}>{t("viewer.download")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DocumentViewerToolbar
|
||||
isPdf={isPdf}
|
||||
currentPage={currentPage}
|
||||
numPages={numPages}
|
||||
scale={scale}
|
||||
onScaleChange={setScale}
|
||||
onMovePage={movePage}
|
||||
dataUri={dataUri}
|
||||
downloadName={downloadName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</HeaderBand>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { Toast } from "@base-ui/react/toast";
|
||||
import {
|
||||
CaretLeftIcon,
|
||||
CaretRightIcon,
|
||||
DownloadSimpleIcon,
|
||||
LinkSimpleIcon,
|
||||
MagnifyingGlassMinusIcon,
|
||||
MagnifyingGlassPlusIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
|
||||
import { Separator } from "@probo/ui/src/v2/Separator/Separator";
|
||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { downloadDataUri } from "../_lib/dataUri";
|
||||
|
||||
import { documentViewerToolbar } from "./variants";
|
||||
|
||||
const MIN_SCALE = 0.5;
|
||||
const MAX_SCALE = 3;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
interface DocumentViewerToolbarProps {
|
||||
// Whether PDF page/zoom controls are shown (only for PDF previews).
|
||||
isPdf: boolean;
|
||||
currentPage: number;
|
||||
numPages: number;
|
||||
scale: number;
|
||||
onScaleChange: (scale: number) => void;
|
||||
// Moves the preview by one page in the given direction.
|
||||
onMovePage: (direction: 1 | -1) => void;
|
||||
// The exported base64 data URI, or null while it is still loading.
|
||||
dataUri: string | null;
|
||||
// File name used when downloading.
|
||||
downloadName: string;
|
||||
}
|
||||
|
||||
// Viewer chrome under the title: page navigation + zoom for PDFs, plus copy
|
||||
// link and download actions shared by every previewable file type.
|
||||
export function DocumentViewerToolbar({
|
||||
isPdf,
|
||||
currentPage,
|
||||
numPages,
|
||||
scale,
|
||||
onScaleChange,
|
||||
onMovePage,
|
||||
dataUri,
|
||||
downloadName,
|
||||
}: DocumentViewerToolbarProps) {
|
||||
const { t } = useTranslation("documents");
|
||||
const toast = Toast.useToastManager();
|
||||
const slots = documentViewerToolbar();
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(window.location.href).then(
|
||||
() => toast.add({ title: t("viewer.linkCopied"), type: "success" }),
|
||||
() => {},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (dataUri) {
|
||||
downloadDataUri(dataUri, downloadName);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={slots.root()}>
|
||||
<div className={slots.start()}>
|
||||
{isPdf && (
|
||||
<>
|
||||
<div className={slots.controls()}>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
aria-label={t("common.previousPage")}
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => onMovePage(-1)}
|
||||
>
|
||||
<CaretLeftIcon />
|
||||
</IconButton>
|
||||
<Text size={2} color="neutral">
|
||||
{t("common.pageOf", { current: currentPage, total: numPages })}
|
||||
</Text>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
aria-label={t("common.nextPage")}
|
||||
disabled={currentPage >= numPages}
|
||||
onClick={() => onMovePage(1)}
|
||||
>
|
||||
<CaretRightIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Separator orientation="vertical" className={slots.separator()} />
|
||||
<div className={slots.controls()}>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
aria-label={t("common.zoomOut")}
|
||||
onClick={() => onScaleChange(clamp(scale * 0.8, MIN_SCALE, MAX_SCALE))}
|
||||
>
|
||||
<MagnifyingGlassMinusIcon />
|
||||
</IconButton>
|
||||
<Text size={2} color="neutral">
|
||||
{`${Math.round(scale * 100)}%`}
|
||||
</Text>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
aria-label={t("common.zoomIn")}
|
||||
onClick={() => onScaleChange(clamp(scale * 1.25, MIN_SCALE, MAX_SCALE))}
|
||||
>
|
||||
<MagnifyingGlassPlusIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={slots.actions()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
iconStart={<LinkSimpleIcon />}
|
||||
onClick={handleCopyLink}
|
||||
aria-label={t("viewer.copyLink")}
|
||||
>
|
||||
<span className={slots.actionLabel()}>{t("viewer.copyLink")}</span>
|
||||
</Button>
|
||||
<Separator orientation="vertical" className={slots.separator()} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
iconStart={<DownloadSimpleIcon />}
|
||||
disabled={dataUri == null}
|
||||
onClick={handleDownload}
|
||||
aria-label={t("viewer.download")}
|
||||
>
|
||||
<span className={slots.actionLabel()}>{t("viewer.download")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,21 +51,13 @@ export const pdfPreview = tv({
|
||||
},
|
||||
});
|
||||
|
||||
// Full-page viewer: fixed header band with the title and toolbar above a
|
||||
// scrollable grey stage for the PDF / image / download-fallback body.
|
||||
// Full-page viewer: fixed header band with the title (and optional toolbar)
|
||||
// above a scrollable grey stage for the PDF / image / download-fallback body.
|
||||
export const documentViewer = tv({
|
||||
slots: {
|
||||
root: "flex h-full min-h-0 flex-1 flex-col",
|
||||
header: "flex w-full flex-col gap-3",
|
||||
back: "-ml-2 self-start",
|
||||
// Stay on one row so page/zoom controls and copy/download share a baseline;
|
||||
// icon-only action labels on max-sm keep this viable on phones.
|
||||
toolbar: "flex min-h-16 flex-wrap items-center justify-between gap-x-4 gap-y-2",
|
||||
toolbarStart: "flex min-w-0 flex-wrap items-center gap-2",
|
||||
controls: "flex items-center gap-1",
|
||||
actions: "flex shrink-0 items-center gap-2",
|
||||
actionLabel: "max-sm:hidden",
|
||||
separator: "h-6 max-sm:hidden",
|
||||
body: "min-h-0 flex-1",
|
||||
stage: "grid h-full place-items-center bg-sand-3",
|
||||
imageStage: "grid h-full place-items-center overflow-auto bg-sand-3 p-8 max-md:p-4",
|
||||
@@ -73,3 +65,16 @@ export const documentViewer = tv({
|
||||
spinner: "size-6 animate-spin text-sand-a10",
|
||||
},
|
||||
});
|
||||
|
||||
// Viewer toolbar: stay on one row so page/zoom controls and copy/download share
|
||||
// a baseline; icon-only action labels on max-sm keep this viable on phones.
|
||||
export const documentViewerToolbar = tv({
|
||||
slots: {
|
||||
root: "flex min-h-16 flex-wrap items-center justify-between gap-x-4 gap-y-2",
|
||||
start: "flex min-w-0 flex-wrap items-center gap-2",
|
||||
controls: "flex items-center gap-1",
|
||||
actions: "flex shrink-0 items-center gap-2",
|
||||
actionLabel: "max-sm:hidden",
|
||||
separator: "h-6 max-sm:hidden",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user