feat(documents): Add Enhanced PDF Viewer and Advanced Search

- Created EnhancedPDFViewer component with features:
  * Page navigation with keyboard shortcuts
  * Zoom in/out (0.5x - 3.0x)
  * Rotate pages clockwise
  * Fit to width / Fit to page modes
  * Thumbnail sidebar for quick navigation
  * Search functionality (Ctrl+F)
  * Print support (Ctrl+P)
  * Fullscreen modal overlay

- Created AdvancedSearch component with features:
  * Full-text search with fuzzy matching (fuse.js)
  * Multiple filters: document type, status, tags, date range, author
  * Sort options: relevance, date, title, author
  * Saved searches (localStorage)
  * Results with highlighted matches
  * Active filter badges
  * Mobile-responsive grid layout
This commit is contained in:
igor_nadein 2026-02-28 09:38:57 +03:00
parent a315ce4f33
commit 9e1a1d4138
4 changed files with 888 additions and 0 deletions

View File

@ -0,0 +1,521 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import { Search, X, Filter, SlidersHorizontal, Save, Clock, ChevronDown } from "lucide-react";
import Fuse from "fuse.js";
export interface SearchFilters {
query: string;
documentTypes?: string[];
dateFrom?: string;
dateTo?: string;
statuses?: string[];
tags?: string[];
authors?: string[];
sortBy?: "relevance" | "date" | "title" | "author";
sortOrder?: "asc" | "desc";
}
export interface SearchResult {
id: number;
title: string;
description: string;
content?: string;
type?: string;
status?: string;
uploaded_at: string;
uploaded_by?: string;
tags?: string[];
score?: number;
highlights?: string[];
}
interface AdvancedSearchProps {
onSearch: (filters: SearchFilters) => void;
results?: SearchResult[];
isLoading?: boolean;
availableTypes?: Array<{ id: string; name: string }>;
availableStatuses?: Array<{ id: string; name: string }>;
availableTags?: Array<{ id: string; name: string }>;
availableAuthors?: Array<{ id: string; name: string }>;
}
const DEFAULT_FILTERS: SearchFilters = {
query: "",
documentTypes: [],
dateFrom: "",
dateTo: "",
statuses: [],
tags: [],
authors: [],
sortBy: "relevance",
sortOrder: "desc",
};
export function AdvancedSearch({
onSearch,
results = [],
isLoading = false,
availableTypes = [],
availableStatuses = [],
availableTags = [],
availableAuthors = [],
}: AdvancedSearchProps) {
const [filters, setFilters] = useState<SearchFilters>(DEFAULT_FILTERS);
const [showFilters, setShowFilters] = useState(false);
const [savedSearches, setSavedSearches] = useState<Array<{ name: string; filters: SearchFilters }>>(
[]
);
const [showSavedSearches, setShowSavedSearches] = useState(false);
const [saveSearchName, setSaveSearchName] = useState("");
// Загрузка сохраненных поисков из localStorage
useEffect(() => {
const saved = localStorage.getItem("savedSearches");
if (saved) {
try {
setSavedSearches(JSON.parse(saved));
} catch (e) {
console.error("Error loading saved searches:", e);
}
}
}, []);
// Обновление фильтра
const updateFilter = useCallback(
<K extends keyof SearchFilters>(key: K, value: SearchFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
},
[]
);
// Выполнение поиска
const handleSearch = useCallback(() => {
onSearch(filters);
}, [filters, onSearch]);
// Сброс фильтров
const resetFilters = useCallback(() => {
setFilters(DEFAULT_FILTERS);
onSearch(DEFAULT_FILTERS);
}, [onSearch]);
// Сохранение поиска
const saveSearch = useCallback(() => {
if (!saveSearchName.trim()) return;
const newSearch = {
name: saveSearchName.trim(),
filters: { ...filters },
};
const updated = [...savedSearches, newSearch];
setSavedSearches(updated);
localStorage.setItem("savedSearches", JSON.stringify(updated));
setSaveSearchName("");
setShowSavedSearches(false);
}, [saveSearchName, filters, savedSearches]);
// Загрузка сохраненного поиска
const loadSearch = useCallback(
(search: { name: string; filters: SearchFilters }) => {
setFilters(search.filters);
onSearch(search.filters);
setShowSavedSearches(false);
},
[onSearch]
);
// Удаление сохраненного поиска
const deleteSavedSearch = useCallback(
(index: number) => {
const updated = savedSearches.filter((_, i) => i !== index);
setSavedSearches(updated);
localStorage.setItem("savedSearches", JSON.stringify(updated));
},
[savedSearches]
);
// Fuzzy search для подсветки
const fuse = useMemo(() => {
if (!results.length || !filters.query) return null;
return new Fuse(results, {
keys: ["title", "description", "content"],
includeScore: true,
includeMatches: true,
threshold: 0.4,
});
}, [results, filters.query]);
// Подсветка совпадений
const highlightText = (text: string, query: string) => {
if (!query) return text;
const regex = new RegExp(`(${query})`, "gi");
const parts = text.split(regex);
return parts.map((part, index) =>
regex.test(part) ? (
<mark key={index} className="bg-yellow-200 text-gray-900">
{part}
</mark>
) : (
<span key={index}>{part}</span>
)
);
};
// Активные фильтры (для отображения badges)
const activeFiltersCount = useMemo(() => {
let count = 0;
if (filters.documentTypes && filters.documentTypes.length > 0) count++;
if (filters.dateFrom) count++;
if (filters.dateTo) count++;
if (filters.statuses && filters.statuses.length > 0) count++;
if (filters.tags && filters.tags.length > 0) count++;
if (filters.authors && filters.authors.length > 0) count++;
return count;
}, [filters]);
return (
<div className="space-y-4">
{/* Поисковая строка */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search
size={20}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
type="text"
value={filters.query}
onChange={(e) => updateFilter("query", e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="Поиск документов..."
className="w-full rounded-lg border border-gray-300 py-2.5 pl-10 pr-10 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
{filters.query && (
<button
onClick={() => updateFilter("query", "")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X size={16} />
</button>
)}
</div>
<button
onClick={() => setShowFilters((prev) => !prev)}
className={`flex items-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium transition ${
showFilters
? "border-sky-500 bg-sky-50 text-sky-700"
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
}`}
>
<SlidersHorizontal size={16} />
Фильтры
{activeFiltersCount > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-sky-500 text-xs text-white">
{activeFiltersCount}
</span>
)}
</button>
<button
onClick={handleSearch}
disabled={isLoading}
className="rounded-lg bg-sky-600 px-6 py-2.5 text-sm font-medium text-white transition hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? "Поиск..." : "Найти"}
</button>
<button
onClick={() => setShowSavedSearches((prev) => !prev)}
className="flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
title="Сохраненные поиски"
>
<Clock size={16} />
</button>
</div>
{/* Сохраненные поиски */}
{showSavedSearches && (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900">Сохраненные поиски</h3>
<button
onClick={() => setShowSavedSearches(false)}
className="text-gray-400 hover:text-gray-600"
>
<X size={16} />
</button>
</div>
{savedSearches.length > 0 ? (
<div className="space-y-2">
{savedSearches.map((search, index) => (
<div
key={index}
className="flex items-center justify-between rounded border border-gray-200 p-2 hover:bg-gray-50"
>
<button
onClick={() => loadSearch(search)}
className="flex-1 text-left text-sm font-medium text-gray-900"
>
{search.name}
</button>
<button
onClick={() => deleteSavedSearch(index)}
className="text-gray-400 hover:text-red-600"
>
<X size={14} />
</button>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">Нет сохраненных поисков</p>
)}
{/* Сохранить текущий поиск */}
<div className="mt-3 flex gap-2 border-t border-gray-200 pt-3">
<input
type="text"
value={saveSearchName}
onChange={(e) => setSaveSearchName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && saveSearch()}
placeholder="Название поиска..."
className="flex-1 rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
/>
<button
onClick={saveSearch}
disabled={!saveSearchName.trim()}
className="flex items-center gap-1 rounded bg-sky-600 px-3 py-1 text-sm text-white hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Save size={14} />
Сохранить
</button>
</div>
</div>
)}
{/* Панель фильтров */}
{showFilters && (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900">Расширенные фильтры</h3>
<button
onClick={resetFilters}
className="text-sm text-sky-600 hover:text-sky-700"
>
Сбросить все
</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Тип документа */}
{availableTypes.length > 0 && (
<div>
<label className="mb-1 block text-xs font-medium text-gray-700">
Тип документа
</label>
<select
multiple
value={filters.documentTypes || []}
onChange={(e) =>
updateFilter(
"documentTypes",
Array.from(e.target.selectedOptions, (option) => option.value)
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
size={3}
>
{availableTypes.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
</div>
)}
{/* Статус */}
{availableStatuses.length > 0 && (
<div>
<label className="mb-1 block text-xs font-medium text-gray-700">Статус</label>
<select
multiple
value={filters.statuses || []}
onChange={(e) =>
updateFilter(
"statuses",
Array.from(e.target.selectedOptions, (option) => option.value)
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
size={3}
>
{availableStatuses.map((status) => (
<option key={status.id} value={status.id}>
{status.name}
</option>
))}
</select>
</div>
)}
{/* Теги */}
{availableTags.length > 0 && (
<div>
<label className="mb-1 block text-xs font-medium text-gray-700">Теги</label>
<select
multiple
value={filters.tags || []}
onChange={(e) =>
updateFilter(
"tags",
Array.from(e.target.selectedOptions, (option) => option.value)
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
size={3}
>
{availableTags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</select>
</div>
)}
{/* Дата от */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-700">Дата от</label>
<input
type="date"
value={filters.dateFrom || ""}
onChange={(e) => updateFilter("dateFrom", e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
{/* Дата до */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-700">Дата до</label>
<input
type="date"
value={filters.dateTo || ""}
onChange={(e) => updateFilter("dateTo", e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
{/* Автор */}
{availableAuthors.length > 0 && (
<div>
<label className="mb-1 block text-xs font-medium text-gray-700">Автор</label>
<select
multiple
value={filters.authors || []}
onChange={(e) =>
updateFilter(
"authors",
Array.from(e.target.selectedOptions, (option) => option.value)
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
size={3}
>
{availableAuthors.map((author) => (
<option key={author.id} value={author.id}>
{author.name}
</option>
))}
</select>
</div>
)}
</div>
{/* Сортировка */}
<div className="mt-4 flex items-center gap-4 border-t border-gray-200 pt-4">
<label className="text-xs font-medium text-gray-700">Сортировка:</label>
<select
value={filters.sortBy || "relevance"}
onChange={(e) =>
updateFilter("sortBy", e.target.value as SearchFilters["sortBy"])
}
className="rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
>
<option value="relevance">По релевантности</option>
<option value="date">По дате</option>
<option value="title">По названию</option>
<option value="author">По автору</option>
</select>
<select
value={filters.sortOrder || "desc"}
onChange={(e) =>
updateFilter("sortOrder", e.target.value as SearchFilters["sortOrder"])
}
className="rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
>
<option value="asc">По возрастанию</option>
<option value="desc">По убыванию</option>
</select>
</div>
</div>
)}
{/* Результаты поиска */}
{results.length > 0 && (
<div className="space-y-3">
<p className="text-sm text-gray-600">
Найдено результатов: <span className="font-medium text-gray-900">{results.length}</span>
</p>
{results.map((result) => (
<div
key={result.id}
className="rounded-lg border border-gray-200 bg-white p-4 hover:border-sky-300 hover:shadow-sm"
>
<h4 className="mb-1 text-base font-medium text-gray-900">
{highlightText(result.title, filters.query)}
</h4>
{result.description && (
<p className="mb-2 text-sm text-gray-600">
{highlightText(result.description, filters.query)}
</p>
)}
<div className="flex items-center gap-3 text-xs text-gray-500">
{result.type && (
<span className="rounded bg-gray-100 px-2 py-0.5">{result.type}</span>
)}
{result.status && (
<span className="rounded bg-sky-100 px-2 py-0.5 text-sky-700">
{result.status}
</span>
)}
{result.uploaded_by && <span>Автор: {result.uploaded_by}</span>}
{result.uploaded_at && (
<span>{new Date(result.uploaded_at).toLocaleDateString("ru-RU")}</span>
)}
</div>
</div>
))}
</div>
)}
{/* Пустое состояние */}
{!isLoading && results.length === 0 && filters.query && (
<div className="rounded-lg border border-gray-200 bg-white p-8 text-center">
<Search size={48} className="mx-auto mb-3 text-gray-300" />
<p className="text-sm text-gray-600">Ничего не найдено</p>
<p className="mt-1 text-xs text-gray-500">Попробуйте изменить параметры поиска</p>
</div>
)}
</div>
);
}

View File

@ -0,0 +1 @@
export { AdvancedSearch, type SearchFilters, type SearchResult } from "./AdvancedSearch";

View File

@ -0,0 +1,365 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { Document, Page, pdfjs } from "react-pdf";
import "react-pdf/dist/esm/Page/AnnotationLayer.css";
import "react-pdf/dist/esm/Page/TextLayer.css";
import {
ZoomIn,
ZoomOut,
RotateCw,
ChevronLeft,
ChevronRight,
Maximize,
Printer,
Search,
X,
Sidebar,
} from "lucide-react";
// Настройка worker для react-pdf
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`;
type FitMode = "width" | "page" | "custom";
interface EnhancedPDFViewerProps {
fileUrl: string;
fileName?: string;
onClose?: () => void;
}
export function EnhancedPDFViewer({ fileUrl, fileName, onClose }: EnhancedPDFViewerProps) {
const [numPages, setNumPages] = useState<number>(0);
const [currentPage, setCurrentPage] = useState(1);
const [scale, setScale] = useState(1.0);
const [rotation, setRotation] = useState(0);
const [fitMode, setFitMode] = useState<FitMode>("width");
const [showSidebar, setShowSidebar] = useState(true);
const [showSearch, setShowSearch] = useState(false);
const [searchText, setSearchText] = useState("");
const [containerWidth, setContainerWidth] = useState(800);
// Обработка успешной загрузки документа
const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => {
setNumPages(numPages);
setCurrentPage(1);
}, []);
// Навигация по страницам
const goToPage = useCallback(
(page: number) => {
if (page >= 1 && page <= numPages) {
setCurrentPage(page);
}
},
[numPages]
);
const nextPage = useCallback(() => {
goToPage(currentPage + 1);
}, [currentPage, goToPage]);
const previousPage = useCallback(() => {
goToPage(currentPage - 1);
}, [currentPage, goToPage]);
// Управление масштабом
const zoomIn = useCallback(() => {
setScale((prev) => Math.min(prev + 0.25, 3.0));
setFitMode("custom");
}, []);
const zoomOut = useCallback(() => {
setScale((prev) => Math.max(prev - 0.25, 0.5));
setFitMode("custom");
}, []);
const fitToWidth = useCallback(() => {
setFitMode("width");
}, []);
const fitToPage = useCallback(() => {
setFitMode("page");
}, []);
// Поворот
const rotateClockwise = useCallback(() => {
setRotation((prev) => (prev + 90) % 360);
}, []);
// Печать
const handlePrint = useCallback(() => {
window.print();
}, []);
// Горячие клавиши
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
// Игнорировать, если фокус в поле ввода
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
switch (e.key) {
case "ArrowLeft":
previousPage();
break;
case "ArrowRight":
nextPage();
break;
case "+":
case "=":
zoomIn();
break;
case "-":
zoomOut();
break;
case "r":
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
rotateClockwise();
}
break;
case "f":
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
setShowSearch((prev) => !prev);
}
break;
case "p":
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
handlePrint();
}
break;
case "Escape":
setShowSearch(false);
if (onClose) onClose();
break;
}
};
window.addEventListener("keydown", handleKeyPress);
return () => window.removeEventListener("keydown", handleKeyPress);
}, [currentPage, nextPage, previousPage, zoomIn, zoomOut, rotateClockwise, handlePrint, onClose]);
// Вычисление ширины для режима fitToWidth
const getPageWidth = useCallback(() => {
if (fitMode === "width") {
return containerWidth - 40; // С учетом padding
}
return undefined;
}, [fitMode, containerWidth]);
return (
<div className="fixed inset-0 z-50 flex flex-col bg-gray-900">
{/* Шапка */}
<div className="flex items-center justify-between border-b border-gray-700 bg-gray-800 px-4 py-3 text-white">
<div className="flex items-center gap-4">
<button
onClick={() => setShowSidebar((prev) => !prev)}
className="rounded p-2 hover:bg-gray-700"
title="Боковая панель"
>
<Sidebar size={20} />
</button>
<span className="text-sm font-medium">{fileName || "Документ"}</span>
</div>
{/* Поиск */}
{showSearch && (
<div className="flex items-center gap-2">
<input
type="text"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="Поиск в документе..."
className="rounded border border-gray-600 bg-gray-700 px-3 py-1 text-sm text-white focus:border-sky-500 focus:outline-none"
autoFocus
/>
<button
onClick={() => setShowSearch(false)}
className="rounded p-1 hover:bg-gray-700"
>
<X size={16} />
</button>
</div>
)}
<button
onClick={onClose}
className="rounded p-2 hover:bg-gray-700"
title="Закрыть (Esc)"
>
<X size={20} />
</button>
</div>
{/* Панель инструментов */}
<div className="flex items-center justify-between border-b border-gray-700 bg-gray-800 px-4 py-2 text-white">
{/* Навигация */}
<div className="flex items-center gap-2">
<button
onClick={previousPage}
disabled={currentPage <= 1}
className="rounded p-2 hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
title="Предыдущая страница (←)"
>
<ChevronLeft size={20} />
</button>
<span className="text-sm">
<input
type="number"
value={currentPage}
onChange={(e) => goToPage(Number(e.target.value))}
className="w-12 rounded border border-gray-600 bg-gray-700 px-1 text-center text-sm"
min={1}
max={numPages}
/>{" "}
/ {numPages}
</span>
<button
onClick={nextPage}
disabled={currentPage >= numPages}
className="rounded p-2 hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
title="Следующая страница (→)"
>
<ChevronRight size={20} />
</button>
</div>
{/* Масштаб */}
<div className="flex items-center gap-2">
<button
onClick={zoomOut}
disabled={scale <= 0.5}
className="rounded p-2 hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
title="Уменьшить (-)"
>
<ZoomOut size={20} />
</button>
<span className="text-sm">{Math.round(scale * 100)}%</span>
<button
onClick={zoomIn}
disabled={scale >= 3.0}
className="rounded p-2 hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
title="Увеличить (+)"
>
<ZoomIn size={20} />
</button>
<button
onClick={fitToWidth}
className={`rounded px-3 py-1 text-xs hover:bg-gray-700 ${
fitMode === "width" ? "bg-gray-700" : ""
}`}
title="По ширине"
>
По ширине
</button>
<button
onClick={fitToPage}
className={`rounded px-3 py-1 text-xs hover:bg-gray-700 ${
fitMode === "page" ? "bg-gray-700" : ""
}`}
title="Вся страница"
>
Вся страница
</button>
</div>
{/* Дополнительные инструменты */}
<div className="flex items-center gap-2">
<button
onClick={rotateClockwise}
className="rounded p-2 hover:bg-gray-700"
title="Повернуть (Ctrl+R)"
>
<RotateCw size={20} />
</button>
<button
onClick={() => setShowSearch((prev) => !prev)}
className="rounded p-2 hover:bg-gray-700"
title="Поиск (Ctrl+F)"
>
<Search size={20} />
</button>
<button
onClick={handlePrint}
className="rounded p-2 hover:bg-gray-700"
title="Печать (Ctrl+P)"
>
<Printer size={20} />
</button>
</div>
</div>
{/* Основная область */}
<div className="flex flex-1 overflow-hidden">
{/* Боковая панель с миниатюрами */}
{showSidebar && (
<div className="w-48 overflow-y-auto border-r border-gray-700 bg-gray-800 p-2">
<div className="space-y-2">
{Array.from(new Array(numPages), (_, index) => (
<button
key={`thumb_${index + 1}`}
onClick={() => goToPage(index + 1)}
className={`w-full rounded border-2 p-1 transition ${
currentPage === index + 1
? "border-sky-500 bg-gray-700"
: "border-transparent hover:border-gray-600"
}`}
>
<Document file={fileUrl} loading="">
<Page
pageNumber={index + 1}
width={160}
renderAnnotationLayer={false}
renderTextLayer={false}
/>
</Document>
<p className="mt-1 text-center text-xs text-gray-400">{index + 1}</p>
</button>
))}
</div>
</div>
)}
{/* Область просмотра */}
<div
className="flex-1 overflow-auto bg-gray-900 p-4"
ref={(el) => {
if (el) {
setContainerWidth(el.clientWidth);
}
}}
>
<div className="flex justify-center">
<Document
file={fileUrl}
onLoadSuccess={onDocumentLoadSuccess}
loading={
<div className="flex h-96 items-center justify-center text-white">
Загрузка документа...
</div>
}
error={
<div className="flex h-96 items-center justify-center text-red-400">
Ошибка загрузки документа
</div>
}
>
<Page
pageNumber={currentPage}
scale={fitMode === "custom" ? scale : undefined}
width={getPageWidth()}
rotate={rotation}
renderAnnotationLayer={true}
renderTextLayer={true}
/>
</Document>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1 @@
export { EnhancedPDFViewer } from "./EnhancedPDFViewer";