- 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.
416 lines
12 KiB
TypeScript
416 lines
12 KiB
TypeScript
"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 d’afficher 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>
|
||
)
|
||
}
|