Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/api/views/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,7 @@ export async function POST(request: NextRequest) {
if (
documentVersion.type === "pdf" ||
documentVersion.type === "image" ||
documentVersion.type === "text" ||
documentVersion.type === "video"
) {
documentVersion.file = await getFile({
Expand Down Expand Up @@ -719,6 +720,7 @@ export async function POST(request: NextRequest) {
(documentVersion &&
(documentVersion.type === "pdf" ||
documentVersion.type === "image" ||
documentVersion.type === "text" ||
documentVersion.type === "zip" ||
documentVersion.type === "video" ||
documentVersion.type === "link")) ||
Expand Down
201 changes: 201 additions & 0 deletions components/documents/preview-viewers/preview-text-viewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { useCallback, useEffect, useRef, useState } from "react";

import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";

import { DocumentPreviewData } from "@/lib/types/document-preview";
import { paginateText, TEXT_PAGE_DEFAULTS } from "@/lib/utils/text-pagination";

import { Button } from "@/components/ui/button";

const CANVAS_WIDTH = 816;
const CANVAS_HEIGHT = 1056;
const CANVAS_PADDING = 48;
const FONT_SIZE = 14;
const LINE_HEIGHT = Math.floor(
(CANVAS_HEIGHT - CANVAS_PADDING * 2) / TEXT_PAGE_DEFAULTS.linesPerPage,
);
const FONT_FAMILY = '"Courier New", Courier, monospace';

function renderPageToCanvas(
canvas: HTMLCanvasElement,
lines: string[],
dpr: number,
) {
const ctx = canvas.getContext("2d");
if (!ctx) return;

canvas.width = CANVAS_WIDTH * dpr;
canvas.height = CANVAS_HEIGHT * dpr;
canvas.style.width = `${CANVAS_WIDTH}px`;
canvas.style.height = `${CANVAS_HEIGHT}px`;
ctx.scale(dpr, dpr);

ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);

ctx.font = `${FONT_SIZE}px ${FONT_FAMILY}`;
ctx.fillStyle = "#1a1a1a";
ctx.textBaseline = "top";

for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], CANVAS_PADDING, CANVAS_PADDING + i * LINE_HEIGHT);
}
}

interface PreviewTextViewerProps {
documentData: DocumentPreviewData;
onClose: () => void;
}

export function PreviewTextViewer({
documentData,
onClose,
}: PreviewTextViewerProps) {
const [currentPage, setCurrentPage] = useState(1);
const [pages, setPages] = useState<string[][] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);

const { file, documentName } = documentData;

const numPages = pages?.length ?? 0;

useEffect(() => {
if (!file) return;
let cancelled = false;
async function fetchText() {
try {
setLoading(true);
const response = await fetch(file!);
if (!response.ok) throw new Error("Failed to load text file");
const text = await response.text();
if (cancelled) return;
setPages(paginateText(text));
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load file");
}
} finally {
if (!cancelled) setLoading(false);
}
}
fetchText();
return () => {
cancelled = true;
};
}, [file]);
Comment on lines +64 to +87
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reset viewer state before loading a new file.
Line 64-Line 87 does not clear previous error/pages or reset currentPage, so stale state can leak across file changes.

💡 Proposed fix
   useEffect(() => {
     if (!file) return;
     let cancelled = false;
     async function fetchText() {
       try {
+        setError(null);
+        setPages(null);
+        setCurrentPage(1);
         setLoading(true);
         const response = await fetch(file!);
         if (!response.ok) throw new Error("Failed to load text file");
         const text = await response.text();
         if (cancelled) return;
         setPages(paginateText(text));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (!file) return;
let cancelled = false;
async function fetchText() {
try {
setLoading(true);
const response = await fetch(file!);
if (!response.ok) throw new Error("Failed to load text file");
const text = await response.text();
if (cancelled) return;
setPages(paginateText(text));
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load file");
}
} finally {
if (!cancelled) setLoading(false);
}
}
fetchText();
return () => {
cancelled = true;
};
}, [file]);
useEffect(() => {
if (!file) return;
let cancelled = false;
async function fetchText() {
try {
setError(null);
setPages(null);
setCurrentPage(1);
setLoading(true);
const response = await fetch(file!);
if (!response.ok) throw new Error("Failed to load text file");
const text = await response.text();
if (cancelled) return;
setPages(paginateText(text));
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load file");
}
} finally {
if (!cancelled) setLoading(false);
}
}
fetchText();
return () => {
cancelled = true;
};
}, [file]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/documents/preview-viewers/preview-text-viewer.tsx` around lines 64
- 87, When a new file is loaded the effect's fetchText function doesn't clear
prior viewer state, so stale error/pages/currentPage can leak; inside the
useEffect before starting the async fetch (in the fetchText start or immediately
when file changes) call setError(null), setPages([]) and setCurrentPage(0) and
then setLoading(true) so the viewer is reset for the new file; update the
fetchText/useEffect block (references: useEffect, fetchText, setError, setPages,
setCurrentPage, setLoading, paginateText, file) to perform these resets before
awaiting fetch and keep the existing cancelled checks.


useEffect(() => {
if (!pages || !canvasRef.current) return;
const currentPageLines = pages[currentPage - 1];
if (!currentPageLines) return;
const dpr = window.devicePixelRatio || 1;
renderPageToCanvas(canvasRef.current, currentPageLines, dpr);
}, [pages, currentPage]);

const goToNextPage = useCallback(() => {
if (currentPage < numPages) {
setCurrentPage(currentPage + 1);
}
}, [currentPage, numPages]);

const goToPreviousPage = useCallback(() => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
}, [currentPage]);

useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case "ArrowLeft":
goToPreviousPage();
break;
case "ArrowRight":
goToNextPage();
break;
case "Escape":
onClose();
break;
}
};

document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [goToPreviousPage, goToNextPage, onClose]);

if (!file) {
return (
<div className="flex h-full w-full items-center justify-center">
<p className="text-gray-400">Text file not available</p>
</div>
);
}

if (loading) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent" />
</div>
);
}

if (error || !pages) {
return (
<div className="flex h-full w-full items-center justify-center">
<p className="text-gray-400">{error ?? "Failed to load file"}</p>
</div>
);
}

return (
<div className="relative h-full w-full select-none overflow-hidden">
{/* Document Title & Page Counter */}
<div className="absolute left-1/2 top-4 z-50 -translate-x-1/2">
<div className="rounded-lg bg-black/20 px-3 py-2 text-white">
<span className="text-sm font-medium">
{documentName} - Page {currentPage} of {numPages}
</span>
</div>
</div>

{/* Canvas Content */}
<div className="flex h-full w-full items-center justify-center p-4">
<div className="relative max-h-full max-w-full">
<canvas
ref={canvasRef}
className="max-h-[calc(100vh-120px)] max-w-full rounded shadow-lg"
style={{ objectFit: "contain" }}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
</div>

{/* Navigation */}
{numPages > 1 && (
<>
<Button
variant="ghost"
size="icon"
className="absolute left-2 top-1/2 z-50 h-10 w-10 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40 hover:text-white disabled:opacity-30"
onClick={goToPreviousPage}
disabled={currentPage <= 1}
>
<ChevronLeftIcon className="h-6 w-6" />
</Button>

<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 z-50 h-10 w-10 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40 hover:text-white disabled:opacity-30"
onClick={goToNextPage}
disabled={currentPage >= numPages}
>
<ChevronRightIcon className="h-6 w-6" />
</Button>
Comment on lines +178 to +196
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add accessible names to icon-only pagination buttons.
Line 178 and Line 188 render icon-only controls without aria-label, which makes page navigation unclear for assistive tech users.

♿ Proposed fix
           <Button
             variant="ghost"
             size="icon"
+            aria-label="Previous page"
+            title="Previous page"
             className="absolute left-2 top-1/2 z-50 h-10 w-10 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40 hover:text-white disabled:opacity-30"
             onClick={goToPreviousPage}
             disabled={currentPage <= 1}
           >
             <ChevronLeftIcon className="h-6 w-6" />
           </Button>

           <Button
             variant="ghost"
             size="icon"
+            aria-label="Next page"
+            title="Next page"
             className="absolute right-2 top-1/2 z-50 h-10 w-10 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40 hover:text-white disabled:opacity-30"
             onClick={goToNextPage}
             disabled={currentPage >= numPages}
           >
             <ChevronRightIcon className="h-6 w-6" />
           </Button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Button
variant="ghost"
size="icon"
className="absolute left-2 top-1/2 z-50 h-10 w-10 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40 hover:text-white disabled:opacity-30"
onClick={goToPreviousPage}
disabled={currentPage <= 1}
>
<ChevronLeftIcon className="h-6 w-6" />
</Button>
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 z-50 h-10 w-10 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40 hover:text-white disabled:opacity-30"
onClick={goToNextPage}
disabled={currentPage >= numPages}
>
<ChevronRightIcon className="h-6 w-6" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Previous page"
title="Previous page"
className="absolute left-2 top-1/2 z-50 h-10 w-10 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40 hover:text-white disabled:opacity-30"
onClick={goToPreviousPage}
disabled={currentPage <= 1}
>
<ChevronLeftIcon className="h-6 w-6" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Next page"
title="Next page"
className="absolute right-2 top-1/2 z-50 h-10 w-10 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40 hover:text-white disabled:opacity-30"
onClick={goToNextPage}
disabled={currentPage >= numPages}
>
<ChevronRightIcon className="h-6 w-6" />
</Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/documents/preview-viewers/preview-text-viewer.tsx` around lines
178 - 196, The icon-only pagination Buttons for navigating pages (the Button
components that call goToPreviousPage and goToNextPage and are disabled based on
currentPage/numPages) lack accessible names; add appropriate aria-label
attributes (e.g., aria-label="Previous page" and aria-label="Next page") or
aria-labelledby that include current/total context as needed so screen readers
can announce their purpose, ensuring the labels are present on the Button
elements that wrap ChevronLeftIcon and ChevronRightIcon.

</>
)}
</div>
);
}
8 changes: 8 additions & 0 deletions components/documents/preview-viewers/preview-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DocumentPreviewData } from "@/lib/types/document-preview";

import { PreviewImageViewer } from "./preview-image-viewer";
import { PreviewPagesViewer } from "./preview-pages-viewer";
import { PreviewTextViewer } from "./preview-text-viewer";

interface PreviewViewerProps {
documentData: DocumentPreviewData;
Expand All @@ -24,6 +25,13 @@ export function PreviewViewer({ documentData, onClose }: PreviewViewerProps) {
);
}

// Plain text files
if (documentData.fileType === "text" && documentData.file) {
return (
<PreviewTextViewer documentData={documentData} onClose={onClose} />
);
}

// Excel/CSV files
if (documentData.fileType === "sheet" && documentData.sheetData) {
return (
Expand Down
17 changes: 17 additions & 0 deletions components/view/view-data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import DownloadOnlyViewer from "./viewer/download-only-viewer";
import ImageViewer from "./viewer/image-viewer";
import PagesHorizontalViewer from "./viewer/pages-horizontal-viewer";
import PagesVerticalViewer from "./viewer/pages-vertical-viewer";
import TextViewer from "./viewer/text-viewer";
import VideoViewer from "./viewer/video-viewer";

const ExcelViewer = dynamic(
Expand Down Expand Up @@ -183,6 +184,22 @@ export default function ViewData({
linkName={link.name ?? `Link #${link.id.slice(-5)}`}
navData={navData}
/>
) : viewData.fileType === "text" ? (
<TextViewer
file={viewData.file!}
screenshotProtectionEnabled={link.enableScreenshotProtection!}
versionNumber={document.versions[0].versionNumber}
showPoweredByBanner={showPoweredByBanner}
viewerEmail={viewerEmail}
watermarkConfig={
link.enableWatermark
? (link.watermarkConfig as WatermarkConfig)
: null
}
ipAddress={viewData.ipAddress}
linkName={link.name ?? `Link #${link.id.slice(-5)}`}
navData={navData}
/>
) : viewData.pages && !document.versions[0].isVertical ? (
<PagesHorizontalViewer
pages={viewData.pages}
Expand Down
Loading