ultisuite-client/components/drive/pdf-preview-viewer.tsx
R3D347HR4Y 6ec95262af Add OnlyOffice integration and update project configurations
- Updated .env.example to include configuration for OnlyOffice Document Server.
- Modified the workspace configuration to remove the drive-suite path.
- Adjusted TypeScript environment imports for consistency.
- Enhanced Next.js configuration to disable canvas in Webpack.
- Updated package.json to include new dependencies for OnlyOffice and PDF.js.
- Added global styles for OnlyOffice theme integration in the CSS.
- Created new layout and page components for the Drive feature, including public sharing and editing functionalities.
- Updated metadata handling across various layouts to reflect the new app structure.
2026-06-07 15:49:21 +02:00

416 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { ChevronDown, ChevronUp, Loader2, Printer, ZoomIn, ZoomOut } from "lucide-react"
import {
getDocument,
GlobalWorkerOptions,
type PDFDocumentLoadingTask,
type PDFDocumentProxy,
} from "pdfjs-dist"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString()
const PAGE_GAP_PX = 24
const MIN_ZOOM = 0.5
const MAX_ZOOM = 2.5
const ZOOM_STEP = 0.15
const RENDER_MARGIN_PX = 320
const HORIZONTAL_PADDING_PX = 48
function fitScaleForPage(pageWidth: number, containerWidth: number): number {
if (pageWidth <= 0 || containerWidth <= 0) return 1
return Math.min(2, Math.max(0.25, containerWidth / pageWidth))
}
async function releasePdfDocument(doc: PDFDocumentProxy | null) {
if (!doc) return
try {
await doc.cleanup()
} catch {
/* already released */
}
}
async function releaseLoadingTask(task: PDFDocumentLoadingTask | null) {
if (!task || task.destroyed) return
try {
await task.destroy()
} catch {
/* already released */
}
}
type PdfPreviewViewerProps = {
blobUrl: string
name: string
}
function PdfPageCanvas({
pdf,
pageNumber,
containerWidth,
zoom,
shouldRender,
}: {
pdf: PDFDocumentProxy
pageNumber: number
containerWidth: number
zoom: number
shouldRender: boolean
}) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const renderTaskRef = useRef<{ cancel: () => void } | null>(null)
useEffect(() => {
if (!shouldRender || containerWidth <= 0 || zoom <= 0) return
let cancelled = false
const canvas = canvasRef.current
if (!canvas) return
;(async () => {
try {
const page = await pdf.getPage(pageNumber)
if (cancelled) return
const base = page.getViewport({ scale: 1 })
const scale = fitScaleForPage(base.width, containerWidth) * zoom
const viewport = page.getViewport({ scale })
const ctx = canvas.getContext("2d")
if (!ctx) return
const outputScale = window.devicePixelRatio || 1
canvas.width = Math.floor(viewport.width * outputScale)
canvas.height = Math.floor(viewport.height * outputScale)
canvas.style.width = `${viewport.width}px`
canvas.style.height = `${viewport.height}px`
ctx.setTransform(outputScale, 0, 0, outputScale, 0, 0)
renderTaskRef.current?.cancel()
const task = page.render({ canvasContext: ctx, viewport, canvas })
renderTaskRef.current = task
await task.promise
} catch {
/* render cancelled or viewer unmounted */
}
})()
return () => {
cancelled = true
renderTaskRef.current?.cancel()
renderTaskRef.current = null
}
}, [pdf, pageNumber, containerWidth, zoom, shouldRender])
return (
<canvas
ref={canvasRef}
className="block bg-white"
aria-hidden
/>
)
}
function printPdfBlob(blobUrl: string, title: string) {
const frame = document.createElement("iframe")
frame.style.position = "fixed"
frame.style.right = "0"
frame.style.bottom = "0"
frame.style.width = "0"
frame.style.height = "0"
frame.style.border = "0"
frame.setAttribute("title", title)
frame.src = blobUrl
const cleanup = () => {
frame.remove()
}
frame.onload = () => {
try {
frame.contentWindow?.focus()
frame.contentWindow?.print()
} finally {
window.setTimeout(cleanup, 1000)
}
}
document.body.appendChild(frame)
}
export function PdfPreviewViewer({ blobUrl, name }: PdfPreviewViewerProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const pageRefs = useRef<Map<number, HTMLDivElement>>(new Map())
const loadingTaskRef = useRef<PDFDocumentLoadingTask | null>(null)
const [pdf, setPdf] = useState<PDFDocumentProxy | null>(null)
const [numPages, setNumPages] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [currentPage, setCurrentPage] = useState(1)
const [containerWidth, setContainerWidth] = useState(0)
const [zoom, setZoom] = useState(1)
const [visiblePages, setVisiblePages] = useState<Set<number>>(() => new Set([1]))
useEffect(() => {
let destroyed = false
let doc: PDFDocumentProxy | null = null
loadingTaskRef.current = null
setLoading(true)
setError(null)
setPdf(null)
setNumPages(0)
setCurrentPage(1)
setZoom(1)
setVisiblePages(new Set([1]))
pageRefs.current.clear()
;(async () => {
try {
const response = await fetch(blobUrl)
if (!response.ok) throw new Error("fetch failed")
const data = await response.arrayBuffer()
if (destroyed) return
const task = getDocument({ data, isEvalSupported: false })
loadingTaskRef.current = task
const loaded = await task.promise
if (destroyed) {
await releasePdfDocument(loaded)
return
}
doc = loaded
setPdf(loaded)
setNumPages(loaded.numPages)
setLoading(false)
} catch {
if (!destroyed) {
setError("Impossible dafficher ce PDF.")
setLoading(false)
}
}
})()
return () => {
destroyed = true
void releaseLoadingTask(loadingTaskRef.current)
void releasePdfDocument(doc)
}
}, [blobUrl])
const updateContainerWidth = useCallback(() => {
const el = scrollRef.current
if (!el) return
const width = el.clientWidth - HORIZONTAL_PADDING_PX
if (width > 0) setContainerWidth(width)
}, [])
useEffect(() => {
if (!pdf) return
updateContainerWidth()
const el = scrollRef.current
if (!el) return
const ro = new ResizeObserver(updateContainerWidth)
ro.observe(el)
return () => ro.disconnect()
}, [pdf, updateContainerWidth])
const updateCurrentPageFromScroll = useCallback(() => {
const root = scrollRef.current
if (!root || pageRefs.current.size === 0) return
const focusY = root.getBoundingClientRect().top + root.clientHeight * 0.35
let bestPage = 1
let bestDist = Number.POSITIVE_INFINITY
pageRefs.current.forEach((node, page) => {
const dist = Math.abs(node.getBoundingClientRect().top - focusY)
if (dist < bestDist) {
bestDist = dist
bestPage = page
}
})
setCurrentPage(bestPage)
}, [])
useEffect(() => {
if (!pdf || numPages === 0) return
const root = scrollRef.current
if (!root) return
const observer = new IntersectionObserver(
(entries) => {
setVisiblePages((prev) => {
const merged = new Set(prev)
for (const entry of entries) {
const page = Number((entry.target as HTMLElement).dataset.page)
if (!page) continue
if (entry.isIntersecting) merged.add(page)
}
return merged
})
updateCurrentPageFromScroll()
},
{ root, rootMargin: `${RENDER_MARGIN_PX}px 0px`, threshold: 0 },
)
const nodes = [...pageRefs.current.values()]
nodes.forEach((node) => observer.observe(node))
root.addEventListener("scroll", updateCurrentPageFromScroll, { passive: true })
updateCurrentPageFromScroll()
return () => {
observer.disconnect()
root.removeEventListener("scroll", updateCurrentPageFromScroll)
}
}, [pdf, numPages, updateCurrentPageFromScroll])
const scrollToPage = (page: number) => {
const node = pageRefs.current.get(page)
node?.scrollIntoView({ behavior: "smooth", block: "start" })
setCurrentPage(page)
}
const setPageRef = (page: number) => (node: HTMLDivElement | null) => {
if (node) pageRefs.current.set(page, node)
else pageRefs.current.delete(page)
}
if (loading) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-zinc-400">
<Loader2 className="h-10 w-10 animate-spin" aria-hidden />
<span className="text-sm">Ouverture du PDF</span>
</div>
)
}
if (error || !pdf) {
return <p className="text-center text-sm text-zinc-400">{error ?? "Aperçu indisponible."}</p>
}
const zoomPercent = Math.round(zoom * 100)
return (
<div className="flex h-full min-h-0 flex-col">
<div
ref={scrollRef}
className="min-h-0 flex-1 overflow-auto overscroll-contain bg-zinc-900/60"
aria-label={`Aperçu PDF : ${name}`}
>
<div
className={cn(
"mx-auto flex w-max min-w-full flex-col items-center px-4 py-6",
zoom <= 1 && "max-w-5xl",
)}
style={{ gap: PAGE_GAP_PX }}
>
{Array.from({ length: numPages }, (_, i) => {
const pageNumber = i + 1
return (
<div
key={pageNumber}
ref={setPageRef(pageNumber)}
data-page={pageNumber}
className={cn(
"inline-flex w-max max-w-none flex-col scroll-mt-4",
"rounded-sm bg-white shadow-[0_8px_32px_rgba(0,0,0,0.45)] ring-1 ring-white/10",
)}
>
<div className="flex w-full shrink-0 items-center justify-between border-b border-zinc-200/80 bg-zinc-50 px-3 py-1.5">
<span className="text-xs font-medium tabular-nums text-zinc-500">
Page {pageNumber}
</span>
</div>
<div className="flex shrink-0 justify-center bg-zinc-100 p-2 sm:p-3">
<PdfPageCanvas
pdf={pdf}
pageNumber={pageNumber}
containerWidth={containerWidth}
zoom={zoom}
shouldRender={visiblePages.has(pageNumber) && containerWidth > 0}
/>
</div>
</div>
)
})}
</div>
</div>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-white/10 bg-zinc-950/95 px-4 py-2.5 backdrop-blur-sm">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer text-zinc-400 hover:bg-white/10 hover:text-white"
onClick={() => printPdfBlob(blobUrl, name)}
aria-label="Imprimer"
>
<Printer className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer text-zinc-400 hover:bg-white/10 hover:text-white"
onClick={() => setZoom((z) => Math.max(MIN_ZOOM, z - ZOOM_STEP))}
disabled={zoom <= MIN_ZOOM}
aria-label="Zoom arrière"
>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="min-w-[3.5rem] text-center text-xs tabular-nums text-zinc-400">
{zoomPercent}%
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer text-zinc-400 hover:bg-white/10 hover:text-white"
onClick={() => setZoom((z) => Math.min(MAX_ZOOM, z + ZOOM_STEP))}
disabled={zoom >= MAX_ZOOM}
aria-label="Zoom avant"
>
<ZoomIn className="h-4 w-4" />
</Button>
</div>
<p className="text-sm font-medium tabular-nums text-zinc-200">
Page {currentPage} / {numPages}
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer text-zinc-400 hover:bg-white/10 hover:text-white"
onClick={() => scrollToPage(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1}
aria-label="Page précédente"
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer text-zinc-400 hover:bg-white/10 hover:text-white"
onClick={() => scrollToPage(Math.min(numPages, currentPage + 1))}
disabled={currentPage >= numPages}
aria-label="Page suivante"
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}