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:
Émile Ré
2026-07-29 17:22:40 +02:00
parent e9d0825b5e
commit 72ca18e204
4 changed files with 199 additions and 113 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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",
},
});