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 { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||||
|
|
||||||
import { documentViewer } from "./_components/variants";
|
import { documentViewer, documentViewerToolbar } from "./_components/variants";
|
||||||
|
|
||||||
export function DocumentViewerPageSkeleton() {
|
export function DocumentViewerPageSkeleton() {
|
||||||
const slots = documentViewer();
|
const slots = documentViewer();
|
||||||
|
const toolbar = documentViewerToolbar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={slots.root()}>
|
<div className={slots.root()}>
|
||||||
@@ -35,9 +36,9 @@ export function DocumentViewerPageSkeleton() {
|
|||||||
<div className={slots.header()}>
|
<div className={slots.header()}>
|
||||||
<TextSkeleton size={1} className="w-20" />
|
<TextSkeleton size={1} className="w-20" />
|
||||||
<HeadingSkeleton size={7} className="w-80" />
|
<HeadingSkeleton size={7} className="w-80" />
|
||||||
<div className={slots.toolbar()}>
|
<div className={toolbar.root()}>
|
||||||
<ButtonSkeleton size={2} />
|
<ButtonSkeleton size={2} />
|
||||||
<div className={slots.actions()}>
|
<div className={toolbar.actions()}>
|
||||||
<ButtonSkeleton size={2} />
|
<ButtonSkeleton size={2} />
|
||||||
<ButtonSkeleton size={2} />
|
<ButtonSkeleton size={2} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,22 +18,9 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { Toast } from "@base-ui/react/toast";
|
import { CaretLeftIcon, SpinnerGapIcon } from "@phosphor-icons/react";
|
||||||
import {
|
|
||||||
CaretLeftIcon,
|
|
||||||
CaretRightIcon,
|
|
||||||
DownloadSimpleIcon,
|
|
||||||
LinkSimpleIcon,
|
|
||||||
MagnifyingGlassMinusIcon,
|
|
||||||
MagnifyingGlassPlusIcon,
|
|
||||||
SpinnerGapIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
|
||||||
import { Link } from "@probo/ui/src/v2/Button/Link";
|
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 { Heading } from "@probo/ui/src/v2/typography/Heading";
|
||||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -44,13 +31,11 @@ import { dataUriMimeType, downloadDataUri } from "../_lib/dataUri";
|
|||||||
|
|
||||||
import { DocumentDownloadFallback } from "./DocumentDownloadFallback";
|
import { DocumentDownloadFallback } from "./DocumentDownloadFallback";
|
||||||
import { DocumentLocked } from "./DocumentLocked";
|
import { DocumentLocked } from "./DocumentLocked";
|
||||||
|
import { DocumentViewerToolbar } from "./DocumentViewerToolbar";
|
||||||
import type { PdfPreviewHandle } from "./PdfPreview";
|
import type { PdfPreviewHandle } from "./PdfPreview";
|
||||||
import { PdfPreview } from "./PdfPreview";
|
import { PdfPreview } from "./PdfPreview";
|
||||||
import { documentViewer } from "./variants";
|
import { documentViewer } from "./variants";
|
||||||
|
|
||||||
const MIN_SCALE = 0.5;
|
|
||||||
const MAX_SCALE = 3;
|
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number): number {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
return Math.min(Math.max(value, min), max);
|
return Math.min(Math.max(value, min), max);
|
||||||
}
|
}
|
||||||
@@ -83,7 +68,6 @@ interface DocumentViewerProps {
|
|||||||
// place of the preview.
|
// place of the preview.
|
||||||
export function DocumentViewer({ title, dataUri = null, downloadName = title, locked }: DocumentViewerProps) {
|
export function DocumentViewer({ title, dataUri = null, downloadName = title, locked }: DocumentViewerProps) {
|
||||||
const { t } = useTranslation("documents");
|
const { t } = useTranslation("documents");
|
||||||
const toast = Toast.useToastManager();
|
|
||||||
const localizedPath = useLocalizedPath();
|
const localizedPath = useLocalizedPath();
|
||||||
|
|
||||||
const pdfRef = useRef<PdfPreviewHandle>(null);
|
const pdfRef = useRef<PdfPreviewHandle>(null);
|
||||||
@@ -102,13 +86,6 @@ export function DocumentViewer({ title, dataUri = null, downloadName = title, lo
|
|||||||
setCurrentPage(next);
|
setCurrentPage(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
|
||||||
navigator.clipboard.writeText(window.location.href).then(
|
|
||||||
() => toast.add({ title: t("viewer.linkCopied"), type: "success" }),
|
|
||||||
() => {},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
if (dataUri) {
|
if (dataUri) {
|
||||||
downloadDataUri(dataUri, downloadName);
|
downloadDataUri(dataUri, downloadName);
|
||||||
@@ -128,81 +105,16 @@ export function DocumentViewer({ title, dataUri = null, downloadName = title, lo
|
|||||||
{title}
|
{title}
|
||||||
</Heading>
|
</Heading>
|
||||||
{!isLocked && (
|
{!isLocked && (
|
||||||
<div className={slots.toolbar()}>
|
<DocumentViewerToolbar
|
||||||
<div className={slots.toolbarStart()}>
|
isPdf={isPdf}
|
||||||
{isPdf && (
|
currentPage={currentPage}
|
||||||
<>
|
numPages={numPages}
|
||||||
<div className={slots.controls()}>
|
scale={scale}
|
||||||
<IconButton
|
onScaleChange={setScale}
|
||||||
variant="ghost"
|
onMovePage={movePage}
|
||||||
color="neutral"
|
dataUri={dataUri}
|
||||||
aria-label={t("common.previousPage")}
|
downloadName={downloadName}
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</HeaderBand>
|
</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
|
// Full-page viewer: fixed header band with the title (and optional toolbar)
|
||||||
// scrollable grey stage for the PDF / image / download-fallback body.
|
// above a scrollable grey stage for the PDF / image / download-fallback body.
|
||||||
export const documentViewer = tv({
|
export const documentViewer = tv({
|
||||||
slots: {
|
slots: {
|
||||||
root: "flex h-full min-h-0 flex-1 flex-col",
|
root: "flex h-full min-h-0 flex-1 flex-col",
|
||||||
header: "flex w-full flex-col gap-3",
|
header: "flex w-full flex-col gap-3",
|
||||||
back: "-ml-2 self-start",
|
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",
|
body: "min-h-0 flex-1",
|
||||||
stage: "grid h-full place-items-center bg-sand-3",
|
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",
|
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",
|
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