feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
81
apps/web/src/modules/pdf/layout/preview/document-menu.tsx
Normal file
81
apps/web/src/modules/pdf/layout/preview/document-menu.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { usePdf } from "@anaralabs/lector";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@turbostarter/ui-web/dropdown-menu";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
interface DocumentMenuProps {
|
||||
readonly documentUrl: string;
|
||||
}
|
||||
|
||||
export const DocumentMenu = ({ documentUrl }: DocumentMenuProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const pdfDocumentProxy = usePdf((state) => state.pdfDocumentProxy);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (isDownloading) return;
|
||||
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
|
||||
const pdfData = await pdfDocumentProxy.getData();
|
||||
const blob = new Blob([pdfData as BlobPart], { type: "application/pdf" });
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
|
||||
const filename = documentUrl.split("/").pop() ?? "document.pdf";
|
||||
link.download = filename;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t("error.general"));
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenClick = () => {
|
||||
window.open(documentUrl, "_blank");
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Icons.Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading || !pdfDocumentProxy}
|
||||
className="gap-2"
|
||||
>
|
||||
<Icons.DownloadCloud className="size-4" />
|
||||
{isDownloading ? t("downloading") : t("download")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleOpenClick} className="gap-2">
|
||||
<Icons.Link className="size-4" /> {t("open")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentMenu;
|
||||
551
apps/web/src/modules/pdf/layout/preview/highlight-layer.tsx
Normal file
551
apps/web/src/modules/pdf/layout/preview/highlight-layer.tsx
Normal file
@@ -0,0 +1,551 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { usePdfViewer } from "../../context";
|
||||
import { useEmbedding } from "../../hooks";
|
||||
import { useCitationUnit } from "../../hooks/use-citation-unit";
|
||||
|
||||
import type { BoundingBox } from "../../hooks/use-citation-unit";
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Duration in ms before auto-clearing the highlight */
|
||||
const HIGHLIGHT_DURATION_MS = 5000;
|
||||
|
||||
/** Minimum word match percentage to consider a span relevant (legacy fallback) */
|
||||
const MIN_MATCH_PERCENTAGE = 0.25;
|
||||
|
||||
/** CSS class for primary highlight (exact citation - violet) */
|
||||
const HIGHLIGHT_PRIMARY_CLASS = "pdf-citation-primary";
|
||||
|
||||
/** CSS class for secondary highlight (context - yellow) */
|
||||
const HIGHLIGHT_SECONDARY_CLASS = "pdf-citation-secondary";
|
||||
|
||||
/** Legacy class name for backward compatibility */
|
||||
const HIGHLIGHT_CLASS = "pdf-citation-highlight";
|
||||
|
||||
/** Data attribute to mark highlighted spans */
|
||||
const HIGHLIGHT_ATTR = "data-citation-highlight";
|
||||
|
||||
// ============================================================================
|
||||
// Styles
|
||||
// ============================================================================
|
||||
|
||||
/** Injected styles for text span highlighting (two-level: primary + secondary) */
|
||||
const HIGHLIGHT_STYLES = `
|
||||
/* Secondary highlight - yellow/amber for context */
|
||||
.${HIGHLIGHT_SECONDARY_CLASS} {
|
||||
background-color: rgba(250, 204, 21, 0.25) !important;
|
||||
border-radius: 2px;
|
||||
transition: background-color 300ms ease-in-out;
|
||||
}
|
||||
|
||||
/* Primary highlight - violet for exact citation (overrides secondary) */
|
||||
.${HIGHLIGHT_PRIMARY_CLASS} {
|
||||
background-color: rgba(139, 92, 246, 0.4) !important;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 6px rgba(139, 92, 246, 0.5);
|
||||
transition: background-color 300ms ease-in-out;
|
||||
}
|
||||
|
||||
/* Legacy highlight class (backward compatibility) */
|
||||
.${HIGHLIGHT_CLASS} {
|
||||
background-color: rgba(250, 204, 21, 0.4) !important;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 4px rgba(250, 204, 21, 0.6);
|
||||
transition: background-color 300ms ease-in-out;
|
||||
}
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Utilities - Legacy Word Overlap Matching
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Inject highlight styles into document head (once)
|
||||
*/
|
||||
function ensureStylesInjected(): void {
|
||||
if (typeof document === "undefined") return;
|
||||
if (document.getElementById("pdf-highlight-styles")) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = "pdf-highlight-styles";
|
||||
style.textContent = HIGHLIGHT_STYLES;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize text for comparison - removes extra whitespace, lowercases
|
||||
*/
|
||||
function normalizeText(text: string): string {
|
||||
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get significant words from text (words with 3+ characters)
|
||||
*/
|
||||
function getSignificantWords(text: string): Set<string> {
|
||||
const normalized = normalizeText(text);
|
||||
const words = normalized.split(/\s+/).filter((w) => w.length >= 3);
|
||||
return new Set(words);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate word overlap percentage between two texts
|
||||
*/
|
||||
function calculateWordOverlap(text1: string, text2: string): number {
|
||||
const words1 = getSignificantWords(text1);
|
||||
const words2 = getSignificantWords(text2);
|
||||
|
||||
if (words1.size === 0 || words2.size === 0) return 0;
|
||||
|
||||
let matchCount = 0;
|
||||
for (const word of words1) {
|
||||
if (words2.has(word)) matchCount++;
|
||||
}
|
||||
|
||||
return matchCount / Math.min(words1.size, words2.size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find text layer spans that match the embedding content and apply highlights
|
||||
*/
|
||||
function applyHighlightsToSpans(
|
||||
container: Element,
|
||||
embeddingContent: string,
|
||||
): number {
|
||||
// Find the TextLayer - it has class "textLayer" from pdfjs
|
||||
const textLayers = container.querySelectorAll(".textLayer");
|
||||
if (textLayers.length === 0) {
|
||||
console.debug("[HighlightLayer] No TextLayer found");
|
||||
return 0;
|
||||
}
|
||||
|
||||
let highlightCount = 0;
|
||||
|
||||
// Check each text layer
|
||||
for (const textLayer of textLayers) {
|
||||
const spans = textLayer.querySelectorAll("span");
|
||||
|
||||
// For each span, check if it contains significant words from the embedding
|
||||
for (const span of spans) {
|
||||
const spanText = span.textContent ?? "";
|
||||
if (spanText.trim().length < 3) continue;
|
||||
|
||||
const overlap = calculateWordOverlap(spanText, embeddingContent);
|
||||
if (overlap >= MIN_MATCH_PERCENTAGE) {
|
||||
span.classList.add(HIGHLIGHT_CLASS);
|
||||
span.setAttribute(HIGHLIGHT_ATTR, "true");
|
||||
highlightCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no individual spans match, try grouping consecutive spans
|
||||
if (highlightCount === 0) {
|
||||
for (const textLayer of textLayers) {
|
||||
const spans = Array.from(textLayer.querySelectorAll("span"));
|
||||
const combinedText = spans.map((s) => s.textContent ?? "").join(" ");
|
||||
|
||||
// Check if the combined text contains significant content from embedding
|
||||
const overlap = calculateWordOverlap(combinedText, embeddingContent);
|
||||
if (overlap >= MIN_MATCH_PERCENTAGE) {
|
||||
// Find contiguous groups that match
|
||||
for (let i = 0; i < spans.length; i++) {
|
||||
let groupText = "";
|
||||
|
||||
for (let j = i; j < Math.min(i + 10, spans.length); j++) {
|
||||
groupText += " " + (spans[j]?.textContent ?? "");
|
||||
|
||||
const groupOverlap = calculateWordOverlap(
|
||||
groupText,
|
||||
embeddingContent,
|
||||
);
|
||||
if (groupOverlap >= MIN_MATCH_PERCENTAGE) {
|
||||
// Highlight all spans in this group
|
||||
for (let k = i; k <= j; k++) {
|
||||
const span = spans[k];
|
||||
if (span) {
|
||||
span.classList.add(HIGHLIGHT_CLASS);
|
||||
span.setAttribute(HIGHLIGHT_ATTR, "true");
|
||||
highlightCount++;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (highlightCount > 0) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.debug(`[HighlightLayer] Highlighted ${highlightCount} spans (legacy)`);
|
||||
return highlightCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all highlights from the document (clears all highlight classes)
|
||||
*/
|
||||
function clearAllHighlights(container: Element | null): void {
|
||||
if (!container) return;
|
||||
|
||||
const highlighted = container.querySelectorAll(`[${HIGHLIGHT_ATTR}]`);
|
||||
for (const el of highlighted) {
|
||||
el.classList.remove(HIGHLIGHT_PRIMARY_CLASS);
|
||||
el.classList.remove(HIGHLIGHT_SECONDARY_CLASS);
|
||||
el.classList.remove(HIGHLIGHT_CLASS);
|
||||
el.removeAttribute(HIGHLIGHT_ATTR);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utilities - Bounding Box to Text Span Highlighting
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse percentage value from CSS style string (e.g., "left: 31.1%" -> 31.1)
|
||||
*/
|
||||
function parsePercentage(style: string, property: string): number | null {
|
||||
const regex = new RegExp(`${property}:\\s*([\\d.]+)%`);
|
||||
const match = style.match(regex);
|
||||
return match?.[1] ? parseFloat(match[1]) : null;
|
||||
}
|
||||
|
||||
/** Margin settings for highlight levels */
|
||||
interface HighlightMargins {
|
||||
horizontal: number;
|
||||
vertical: number;
|
||||
}
|
||||
|
||||
/** Tight margins for primary highlight (exact citation) */
|
||||
const PRIMARY_MARGINS: HighlightMargins = { horizontal: 1, vertical: 0.5 };
|
||||
|
||||
/** Wider margins for secondary highlight (context) */
|
||||
const SECONDARY_MARGINS: HighlightMargins = { horizontal: 5, vertical: 3 };
|
||||
|
||||
/**
|
||||
* Check if a span's position overlaps with the bbox region
|
||||
* Both bbox (0-1 normalized) and span positions (0-100 percentage) need alignment
|
||||
*/
|
||||
function spanOverlapsBbox(
|
||||
span: HTMLElement,
|
||||
bbox: BoundingBox,
|
||||
margins: HighlightMargins = SECONDARY_MARGINS,
|
||||
): boolean {
|
||||
const style = span.getAttribute("style") ?? "";
|
||||
|
||||
// Parse span position from inline style (percentage-based)
|
||||
const spanLeft = parsePercentage(style, "left");
|
||||
const spanTop = parsePercentage(style, "top");
|
||||
|
||||
if (spanLeft === null || spanTop === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert bbox normalized coords (0-1) to percentage (0-100) for comparison
|
||||
const bboxLeft = bbox.x * 100;
|
||||
const bboxTop = bbox.y * 100;
|
||||
const bboxRight = (bbox.x + bbox.width) * 100;
|
||||
const bboxBottom = (bbox.y + bbox.height) * 100;
|
||||
|
||||
const spanInHorizontalRange = spanLeft >= (bboxLeft - margins.horizontal) &&
|
||||
spanLeft <= (bboxRight + margins.horizontal);
|
||||
const spanInVerticalRange = spanTop >= (bboxTop - margins.vertical) &&
|
||||
spanTop <= (bboxBottom + margins.vertical);
|
||||
|
||||
return spanInHorizontalRange && spanInVerticalRange;
|
||||
}
|
||||
|
||||
/** Highlight level for two-tier highlighting */
|
||||
type HighlightLevel = "primary" | "secondary";
|
||||
|
||||
/**
|
||||
* Find and highlight text layer spans that fall within a bounding box
|
||||
* Supports two-level highlighting: primary (exact citation) and secondary (context)
|
||||
*/
|
||||
function applyBboxHighlightsToSpans(
|
||||
container: Element,
|
||||
bbox: BoundingBox,
|
||||
pageNumber: number,
|
||||
level: HighlightLevel = "primary",
|
||||
): number {
|
||||
// Find the specific page's text layer
|
||||
const pageElement = container.querySelector(`[data-page-number="${pageNumber}"]`);
|
||||
const textLayer = pageElement?.querySelector(".textLayer") ??
|
||||
container.querySelector(".textLayer");
|
||||
|
||||
if (!textLayer) {
|
||||
console.debug("[HighlightLayer] No TextLayer found for bbox highlight");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const spans = textLayer.querySelectorAll("span");
|
||||
let highlightCount = 0;
|
||||
|
||||
// Select margins and class based on level
|
||||
const margins = level === "primary" ? PRIMARY_MARGINS : SECONDARY_MARGINS;
|
||||
const highlightClass = level === "primary" ? HIGHLIGHT_PRIMARY_CLASS : HIGHLIGHT_SECONDARY_CLASS;
|
||||
|
||||
for (const span of spans) {
|
||||
const spanText = span.textContent ?? "";
|
||||
if (spanText.trim().length < 1) continue;
|
||||
|
||||
if (spanOverlapsBbox(span as HTMLElement, bbox, margins)) {
|
||||
span.classList.add(highlightClass);
|
||||
span.setAttribute(HIGHLIGHT_ATTR, level);
|
||||
highlightCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.debug(`[HighlightLayer] Highlighted ${highlightCount} spans via bbox (${level})`);
|
||||
return highlightCount;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* HighlightLayer - Applies CSS highlights to PDF TextLayer spans based on citations
|
||||
*
|
||||
* All highlighting is done by applying CSS classes directly to the PDF.js TextLayer
|
||||
* span elements, ensuring highlights scroll naturally with the document.
|
||||
*
|
||||
* Supports two highlight detection modes:
|
||||
* 1. Bounding Box (WF-0028): Uses bbox coordinates to find spans within the region
|
||||
* 2. Word Overlap (legacy): Falls back to text matching when bbox unavailable
|
||||
*
|
||||
* When `activeHighlight` is set in the PdfViewerContext, this component:
|
||||
* 1. Fetches the citation unit data (or legacy embedding)
|
||||
* 2. If bbox available: Finds TextLayer spans within the bounding box region
|
||||
* 3. If no bbox: Searches TextLayer for spans with matching text content
|
||||
* 4. Applies CSS classes to matching spans for highlighting
|
||||
*
|
||||
* The highlight auto-clears after 5 seconds.
|
||||
*/
|
||||
export const HighlightLayer = memo(function HighlightLayer() {
|
||||
const { activeHighlight, clearHighlight } = usePdfViewer();
|
||||
|
||||
// Try citation unit first (WF-0028)
|
||||
const { data: citationUnit, isLoading: citationLoading } = useCitationUnit(activeHighlight);
|
||||
|
||||
// Fall back to legacy embedding if citation unit not found
|
||||
const shouldFetchEmbedding = Boolean(activeHighlight) && !citationLoading && !citationUnit;
|
||||
const { data: embedding } = useEmbedding(shouldFetchEmbedding ? activeHighlight : null);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isApplyingHighlightsRef = useRef(false);
|
||||
const lastHighlightIdRef = useRef<string | null>(null);
|
||||
|
||||
// Determine which mode to use
|
||||
const hasBbox = citationUnit?.bbox != null;
|
||||
const fallbackContent = citationUnit?.content ?? embedding?.content;
|
||||
|
||||
// Debug logging for highlight mode selection
|
||||
useEffect(() => {
|
||||
if (activeHighlight) {
|
||||
console.debug("[HighlightLayer] Active highlight:", activeHighlight, {
|
||||
citationLoading,
|
||||
hasCitationUnit: Boolean(citationUnit),
|
||||
hasBbox,
|
||||
hasEmbedding: Boolean(embedding),
|
||||
fallbackContentPreview: fallbackContent?.slice(0, 50),
|
||||
});
|
||||
}
|
||||
}, [activeHighlight, citationLoading, citationUnit, hasBbox, embedding, fallbackContent]);
|
||||
|
||||
// Ensure styles are injected (used by both bbox and legacy modes)
|
||||
useEffect(() => {
|
||||
ensureStylesInjected();
|
||||
}, []);
|
||||
|
||||
// Apply bbox-based highlights (CSS on text spans within bounding box)
|
||||
// Two-level highlighting: secondary (yellow, context) then primary (violet, exact citation)
|
||||
// Note: Clearing is handled by the main effect, not here
|
||||
const applyBboxHighlights = useCallback(() => {
|
||||
const container = containerRef.current?.parentElement;
|
||||
if (!container || !citationUnit?.bbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
// First: Apply secondary highlights (yellow) with wider margins for context
|
||||
const secondaryCount = applyBboxHighlightsToSpans(
|
||||
container,
|
||||
citationUnit.bbox,
|
||||
citationUnit.pageNumber,
|
||||
"secondary",
|
||||
);
|
||||
|
||||
// Second: Apply primary highlights (violet) with tight margins for exact citation
|
||||
// This overlays secondary highlights on matching spans (CSS priority handles override)
|
||||
const primaryCount = applyBboxHighlightsToSpans(
|
||||
container,
|
||||
citationUnit.bbox,
|
||||
citationUnit.pageNumber,
|
||||
"primary",
|
||||
);
|
||||
|
||||
console.debug(
|
||||
`[HighlightLayer] Two-level highlights: ${primaryCount} primary (violet), ${secondaryCount} secondary (yellow)`,
|
||||
);
|
||||
}, [citationUnit]);
|
||||
|
||||
// Apply legacy highlights to matching text (word overlap)
|
||||
// Note: Clearing is handled by the main effect, not here
|
||||
const applyLegacyHighlights = useCallback(() => {
|
||||
const container = containerRef.current?.parentElement;
|
||||
if (!container || !fallbackContent) {
|
||||
console.debug("[HighlightLayer] Legacy mode: no container or content", {
|
||||
hasContainer: Boolean(container),
|
||||
contentPreview: fallbackContent?.slice(0, 100),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug("[HighlightLayer] Applying legacy highlights with content:", fallbackContent.slice(0, 100) + "...");
|
||||
|
||||
// Apply new highlights using word overlap matching
|
||||
const count = applyHighlightsToSpans(container, fallbackContent);
|
||||
if (count === 0) {
|
||||
console.warn("[HighlightLayer] No spans matched for legacy highlight. TextLayers found:", container.querySelectorAll(".textLayer").length);
|
||||
}
|
||||
}, [fallbackContent]);
|
||||
|
||||
// Apply highlights when data changes (unified for both modes)
|
||||
useEffect(() => {
|
||||
const container = containerRef.current?.parentElement;
|
||||
|
||||
if (!activeHighlight) {
|
||||
// Only clear if we previously had a highlight
|
||||
if (lastHighlightIdRef.current !== null) {
|
||||
clearAllHighlights(container ?? null);
|
||||
lastHighlightIdRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Choose highlight method based on available data
|
||||
const applyHighlights = hasBbox ? applyBboxHighlights : applyLegacyHighlights;
|
||||
|
||||
// Wait for condition to be ready
|
||||
if (hasBbox && !citationUnit?.bbox) return;
|
||||
if (!hasBbox && !fallbackContent) return;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
// Check if this is a NEW highlight (different ID) - only then clear old highlights
|
||||
const isNewHighlight = lastHighlightIdRef.current !== activeHighlight;
|
||||
if (isNewHighlight) {
|
||||
clearAllHighlights(container);
|
||||
lastHighlightIdRef.current = activeHighlight;
|
||||
}
|
||||
|
||||
// Use ref-based flag to prevent MutationObserver re-triggering across effect runs
|
||||
const safeApplyHighlights = () => {
|
||||
if (isApplyingHighlightsRef.current) return;
|
||||
isApplyingHighlightsRef.current = true;
|
||||
try {
|
||||
// Don't clear again - just apply (clearing already done above for new highlights)
|
||||
applyHighlights();
|
||||
} finally {
|
||||
// Reset flag after a brief delay to allow DOM to settle
|
||||
setTimeout(() => {
|
||||
isApplyingHighlightsRef.current = false;
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Give the TextLayer time to render after page navigation
|
||||
const initialTimeout = setTimeout(safeApplyHighlights, 100);
|
||||
|
||||
// Observe DOM changes in case TextLayer loads later (e.g., lazy loading pages)
|
||||
// Only re-apply when NEW TextLayer content is added, not when we modify highlight classes
|
||||
let debounceTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
// Check if any mutation added new TextLayer spans (not just class changes)
|
||||
const hasNewTextContent = mutations.some((mutation) => {
|
||||
// Only care about added nodes
|
||||
if (mutation.type !== "childList" || mutation.addedNodes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// Check if added nodes contain TextLayer or span elements
|
||||
return Array.from(mutation.addedNodes).some((node) => {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return false;
|
||||
const el = node as Element;
|
||||
return el.classList.contains("textLayer") ||
|
||||
el.querySelector(".textLayer") !== null ||
|
||||
(el.tagName === "SPAN" && el.closest(".textLayer"));
|
||||
});
|
||||
});
|
||||
|
||||
if (!hasNewTextContent) return;
|
||||
|
||||
// Debounce to handle multiple rapid mutations
|
||||
if (debounceTimeout) clearTimeout(debounceTimeout);
|
||||
debounceTimeout = setTimeout(safeApplyHighlights, 150);
|
||||
});
|
||||
|
||||
observer.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearTimeout(initialTimeout);
|
||||
if (debounceTimeout) clearTimeout(debounceTimeout);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [activeHighlight, hasBbox, citationUnit, fallbackContent, applyBboxHighlights, applyLegacyHighlights]);
|
||||
|
||||
// Auto-clear highlight after duration
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (activeHighlight) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
clearAllHighlights(containerRef.current?.parentElement ?? null);
|
||||
clearHighlight();
|
||||
timeoutRef.current = null;
|
||||
}, HIGHLIGHT_DURATION_MS);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeHighlight, clearHighlight]);
|
||||
|
||||
// Cleanup highlights on unmount
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
return () => {
|
||||
clearAllHighlights(container?.parentElement ?? null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// This component no longer renders any visible elements
|
||||
// All highlighting is done via CSS classes on TextLayer spans
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-highlight-layer
|
||||
style={{ display: "none" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
HighlightLayer.displayName = "HighlightLayer";
|
||||
93
apps/web/src/modules/pdf/layout/preview/index.tsx
Normal file
93
apps/web/src/modules/pdf/layout/preview/index.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CanvasLayer,
|
||||
Page,
|
||||
Pages,
|
||||
Root,
|
||||
TextLayer,
|
||||
usePdf,
|
||||
usePdfJump,
|
||||
} from "@anaralabs/lector";
|
||||
import { GlobalWorkerOptions } from "pdfjs-dist";
|
||||
import React, { memo, useEffect } from "react";
|
||||
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { usePdfViewer } from "../../context";
|
||||
|
||||
import { DocumentMenu } from "./document-menu";
|
||||
import { HighlightLayer } from "./highlight-layer";
|
||||
import { PageNavigation } from "./page-navigation";
|
||||
import "./pdf-viewer.css";
|
||||
import { TextHighlightLayer } from "./text-highlight-layer";
|
||||
import { ZoomMenu } from "./zoom-menu";
|
||||
|
||||
// Import extracted PDF text layer styles (avoids problematic pdfjs-dist CSS with relative image imports)
|
||||
|
||||
GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.mjs",
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
interface PdfPreviewProps {
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs lector's current page with our PdfViewerContext.
|
||||
* Also handles navigation requests from citations.
|
||||
* Must be rendered inside lector's Root provider.
|
||||
*/
|
||||
const PageSync = () => {
|
||||
const lectorPage = usePdf((state) => state.currentPage);
|
||||
const { setCurrentPage, pendingNavigation, clearPendingNavigation } =
|
||||
usePdfViewer();
|
||||
const { jumpToPage } = usePdfJump();
|
||||
|
||||
// Sync lector page changes to our context (user scrolling)
|
||||
useEffect(() => {
|
||||
setCurrentPage(lectorPage);
|
||||
}, [lectorPage, setCurrentPage]);
|
||||
|
||||
// Handle navigation requests from citations
|
||||
useEffect(() => {
|
||||
if (pendingNavigation) {
|
||||
const behavior = pendingNavigation.animate ? "smooth" : "auto";
|
||||
jumpToPage(pendingNavigation.page, { behavior });
|
||||
clearPendingNavigation();
|
||||
}
|
||||
}, [pendingNavigation, jumpToPage, clearPendingNavigation]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const PdfPreview = memo<PdfPreviewProps>(({ url }) => {
|
||||
return (
|
||||
<Root
|
||||
className="flex h-full w-full flex-col overflow-hidden"
|
||||
source={url}
|
||||
isZoomFitWidth={true}
|
||||
loader={<Skeleton className="h-full w-full" />}
|
||||
>
|
||||
<PageSync />
|
||||
<div className="relative flex justify-between border-b p-1">
|
||||
<ZoomMenu />
|
||||
<PageNavigation />
|
||||
<DocumentMenu documentUrl={url} />
|
||||
</div>
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<HighlightLayer />
|
||||
<TextHighlightLayer />
|
||||
<Pages className="dark:brightness-80 dark:contrast-228 dark:hue-rotate-180 dark:invert-94">
|
||||
<Page>
|
||||
<CanvasLayer />
|
||||
<TextLayer />
|
||||
</Page>
|
||||
</Pages>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
});
|
||||
|
||||
PdfPreview.displayName = "PdfPreview";
|
||||
80
apps/web/src/modules/pdf/layout/preview/page-navigation.tsx
Normal file
80
apps/web/src/modules/pdf/layout/preview/page-navigation.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { usePdf, usePdfJump } from "@anaralabs/lector";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
export const PageNavigation = () => {
|
||||
const { t } = useTranslation("ai");
|
||||
const pages = usePdf((state) => state.pdfDocumentProxy.numPages);
|
||||
const currentPage = usePdf((state) => state.currentPage);
|
||||
|
||||
const [pageNumber, setPageNumber] = useState<string | number>(currentPage);
|
||||
const { jumpToPage } = usePdfJump();
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
if (currentPage > 1) {
|
||||
jumpToPage(currentPage - 1, { behavior: "auto" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (currentPage < pages) {
|
||||
jumpToPage(currentPage + 1, { behavior: "auto" });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPageNumber(currentPage);
|
||||
}, [currentPage]);
|
||||
|
||||
return (
|
||||
<div className="absolute top-1/2 left-1/2 flex -translate-x-1/2 -translate-y-1/2 transform flex-row items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={currentPage <= 1}
|
||||
aria-label={t("pdf.preview.navigation.previous")}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Icons.ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
value={pageNumber}
|
||||
onChange={(e) => setPageNumber(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
if (currentPage !== Number(e.target.value)) {
|
||||
jumpToPage(Number(e.target.value), {
|
||||
behavior: "auto",
|
||||
});
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className="bg-accent focus:ring-primary/20 w-10 [appearance:textfield] rounded-md border-none text-center text-sm font-medium focus:ring-2 focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
/ {pages}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage >= pages}
|
||||
aria-label={t("pdf.preview.navigation.next")}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Icons.ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
73
apps/web/src/modules/pdf/layout/preview/pdf-viewer.css
Normal file
73
apps/web/src/modules/pdf/layout/preview/pdf-viewer.css
Normal file
@@ -0,0 +1,73 @@
|
||||
/* Essential styles for PDF text layer - extracted from pdfjs-dist/web/pdf_viewer.css */
|
||||
/* This avoids importing the full pdfjs CSS which has problematic relative image imports */
|
||||
|
||||
.textLayer {
|
||||
position: absolute;
|
||||
text-align: initial;
|
||||
inset: 0;
|
||||
overflow: clip;
|
||||
opacity: 1;
|
||||
line-height: 1;
|
||||
-webkit-text-size-adjust: none;
|
||||
-moz-text-size-adjust: none;
|
||||
text-size-adjust: none;
|
||||
forced-color-adjust: none;
|
||||
transform-origin: 0 0;
|
||||
caret-color: CanvasText;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.textLayer.highlighting {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.textLayer :is(span, br) {
|
||||
color: transparent;
|
||||
position: absolute;
|
||||
white-space: pre;
|
||||
cursor: text;
|
||||
transform-origin: 0% 0%;
|
||||
}
|
||||
|
||||
.textLayer {
|
||||
--min-font-size: 1;
|
||||
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
|
||||
--min-font-size-inv: calc(1 / var(--min-font-size));
|
||||
}
|
||||
|
||||
.textLayer > :not(.markedContent),
|
||||
.textLayer .markedContent span:not(.markedContent) {
|
||||
z-index: 1;
|
||||
--font-height: 0;
|
||||
font-size: calc(var(--text-scale-factor) * var(--font-height));
|
||||
--scale-x: 1;
|
||||
--rotate: 0deg;
|
||||
transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
|
||||
}
|
||||
|
||||
.textLayer .markedContent {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.textLayer span[role="img"] {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.textLayer ::-moz-selection {
|
||||
background: rgba(0, 0, 255, 0.25);
|
||||
}
|
||||
|
||||
.textLayer ::selection {
|
||||
background: rgba(0, 0, 255, 0.25);
|
||||
}
|
||||
|
||||
.textLayer br::-moz-selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.textLayer br::selection {
|
||||
background: transparent;
|
||||
}
|
||||
207
apps/web/src/modules/pdf/layout/preview/text-highlight-layer.tsx
Normal file
207
apps/web/src/modules/pdf/layout/preview/text-highlight-layer.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useState } from "react";
|
||||
|
||||
import { usePdfViewer } from "../../context";
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Highlight color - yellow for text highlights */
|
||||
const HIGHLIGHT_COLOR = "rgba(250, 204, 21, 0.4)";
|
||||
|
||||
/** Duration in ms before auto-clearing highlights */
|
||||
const HIGHLIGHT_DURATION_MS = 8000;
|
||||
|
||||
// ============================================================================
|
||||
// Styles
|
||||
// ============================================================================
|
||||
|
||||
/** Injected styles for text span highlighting */
|
||||
const TEXT_HIGHLIGHT_STYLES = `
|
||||
.pdf-text-highlight {
|
||||
background-color: ${HIGHLIGHT_COLOR} !important;
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
animation: pdf-highlight-fade-in 300ms ease-in-out;
|
||||
}
|
||||
@keyframes pdf-highlight-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Inject highlight styles into document head (once)
|
||||
*/
|
||||
function ensureStylesInjected(): void {
|
||||
if (typeof document === "undefined") return;
|
||||
if (document.getElementById("pdf-text-highlight-styles")) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = "pdf-text-highlight-styles";
|
||||
style.textContent = TEXT_HIGHLIGHT_STYLES;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find text in PDF text layer and apply CSS highlights.
|
||||
* Searches the text layer for exact text matches and applies highlight classes.
|
||||
*
|
||||
* @param container - Parent element containing the PDF viewer
|
||||
* @param text - Text phrase to search for and highlight
|
||||
* @param pageNumber - Page number where the text should be found
|
||||
* @returns Number of spans highlighted
|
||||
*/
|
||||
function applyTextHighlights(
|
||||
container: Element,
|
||||
text: string,
|
||||
pageNumber: number,
|
||||
): number {
|
||||
// Find the text layer for this page
|
||||
const pageEl = container.querySelector(`[data-page-number="${pageNumber}"]`);
|
||||
const textLayer =
|
||||
pageEl?.querySelector(".textLayer") ?? container.querySelector(".textLayer");
|
||||
|
||||
if (!textLayer) {
|
||||
console.debug("[TextHighlightLayer] No TextLayer found for page", pageNumber);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const spans = textLayer.querySelectorAll("span");
|
||||
const normalizedSearch = text.toLowerCase().trim();
|
||||
let highlightCount = 0;
|
||||
|
||||
// Build concatenated text from spans to find matches
|
||||
let fullText = "";
|
||||
const spanRanges: { start: number; end: number; span: Element }[] = [];
|
||||
|
||||
for (const span of spans) {
|
||||
const spanText = span.textContent ?? "";
|
||||
const start = fullText.length;
|
||||
fullText += spanText;
|
||||
spanRanges.push({ start, end: fullText.length, span });
|
||||
}
|
||||
|
||||
// Find the search text in the full text
|
||||
const normalizedFull = fullText.toLowerCase();
|
||||
const foundIndex = normalizedFull.indexOf(normalizedSearch);
|
||||
|
||||
if (foundIndex === -1) {
|
||||
console.debug(
|
||||
`[TextHighlightLayer] Text not found: "${text.slice(0, 50)}..."`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const matchEnd = foundIndex + normalizedSearch.length;
|
||||
|
||||
// Highlight spans that overlap with the match
|
||||
for (const { start, end, span } of spanRanges) {
|
||||
if (end > foundIndex && start < matchEnd) {
|
||||
span.classList.add("pdf-text-highlight");
|
||||
span.setAttribute("data-text-highlight", "true");
|
||||
highlightCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return highlightCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all text highlights from the container
|
||||
*/
|
||||
function clearTextHighlights(container: Element | null): void {
|
||||
if (!container) return;
|
||||
|
||||
const highlighted = container.querySelectorAll("[data-text-highlight]");
|
||||
for (const el of highlighted) {
|
||||
el.classList.remove("pdf-text-highlight");
|
||||
el.removeAttribute("data-text-highlight");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* TextHighlightLayer - Applies CSS highlights to PDF TextLayer spans based on exact text matches.
|
||||
*
|
||||
* This component finds and highlights specific text phrases in the PDF viewer.
|
||||
* It uses the pdf.js text layer spans and applies CSS classes for highlighting.
|
||||
*
|
||||
* When `textHighlights` are set in the PdfViewerContext, this component:
|
||||
* 1. Finds the text layer spans that contain the search text
|
||||
* 2. Applies CSS highlight classes to matching spans
|
||||
* 3. Auto-clears highlights after HIGHLIGHT_DURATION_MS
|
||||
*
|
||||
* The component renders a hidden div to get a ref to the parent container.
|
||||
*/
|
||||
export const TextHighlightLayer = memo(function TextHighlightLayer() {
|
||||
const { textHighlights, clearTextHighlights: clearFromContext } = usePdfViewer();
|
||||
|
||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
// Inject styles once on mount
|
||||
useEffect(() => {
|
||||
ensureStylesInjected();
|
||||
}, []);
|
||||
|
||||
// Apply highlights when they change
|
||||
useEffect(() => {
|
||||
const container = containerRef?.parentElement;
|
||||
|
||||
if (!container || textHighlights.length === 0) {
|
||||
if (container) clearTextHighlights(container);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous highlights
|
||||
clearTextHighlights(container);
|
||||
|
||||
// Apply each highlight
|
||||
for (const highlight of textHighlights) {
|
||||
const count = applyTextHighlights(container, highlight.text, highlight.page);
|
||||
console.debug(
|
||||
`[TextHighlightLayer] Applied ${count} highlights for "${highlight.text.slice(0, 30)}..."`,
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-clear after duration
|
||||
const timeout = setTimeout(() => {
|
||||
clearTextHighlights(container);
|
||||
clearFromContext();
|
||||
}, HIGHLIGHT_DURATION_MS);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [textHighlights, containerRef, clearFromContext]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const container = containerRef?.parentElement;
|
||||
if (container) {
|
||||
clearTextHighlights(container);
|
||||
}
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
// Hidden container for ref - renders nothing visible
|
||||
return (
|
||||
<div
|
||||
ref={setContainerRef}
|
||||
data-text-highlight-layer
|
||||
style={{ display: "none" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TextHighlightLayer.displayName = "TextHighlightLayer";
|
||||
74
apps/web/src/modules/pdf/layout/preview/zoom-menu.tsx
Normal file
74
apps/web/src/modules/pdf/layout/preview/zoom-menu.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { usePdf } from "@anaralabs/lector";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@turbostarter/ui-web/dropdown-menu";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
export const ZoomMenu = () => {
|
||||
const { t } = useTranslation("ai");
|
||||
const zoom = usePdf((state) => state.zoom);
|
||||
const setCustomZoom = usePdf((state) => state.updateZoom);
|
||||
const fitToWidth = usePdf((state) => state.zoomFitWidth);
|
||||
|
||||
const handleZoomDecrease = () => setCustomZoom((zoom) => zoom * 0.9);
|
||||
const handleZoomIncrease = () => setCustomZoom((zoom) => zoom * 1.1);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-1"
|
||||
aria-label={t("pdf.preview.zoom.options")}
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
<Icons.ChevronUp className="text-muted-foreground h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start" className="w-40">
|
||||
<DropdownMenuItem className="flex justify-between">
|
||||
<span>{`${Math.round(zoom * 100)}%`}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleZoomDecrease}
|
||||
size="icon"
|
||||
aria-label={t("pdf.preview.zoom.out")}
|
||||
>
|
||||
<Icons.MinusIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleZoomIncrease}
|
||||
size="icon"
|
||||
aria-label={t("pdf.preview.zoom.in")}
|
||||
>
|
||||
<Icons.PlusIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
<DropdownMenuItem onSelect={() => fitToWidth()}>
|
||||
{t("pdf.preview.zoom.fit")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{[0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4].map((zoomLevel) => (
|
||||
<DropdownMenuItem
|
||||
key={zoomLevel}
|
||||
onSelect={() => setCustomZoom(zoomLevel)}
|
||||
>
|
||||
{`${zoomLevel * 100}%`}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user