diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 58e667e4e9..ed1b61e098 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -11328,12 +11328,15 @@ searchPlaceholder = "Search attachments" title = "Attachments" [viewer.bookmarks] +addBookmark = "Add bookmark" bookmarkTitle = "Bookmark title" bookmarkTitleRequired = "Bookmark title is required" closeSidebar = "Close bookmarks sidebar" collapseAll = "Collapse all bookmarks" +empty = "No bookmarks in this document" expandAll = "Expand all bookmarks" searchPlaceholder = "Search bookmarks" +title = "Bookmarks" [viewer.comments] addComment = "Add comment" @@ -11355,10 +11358,12 @@ locateAnnotation = "Locate in document" moreActions = "More actions" nComments_one = "{{count}} comment" nComments_other = "{{count}} comments" +noMatch = "No comments match your search" pageLabel = "Page {{page}}" placingHint = "Click a page to place… (cancel)" removeCommentOnly = "Remove comment only" saveReply = "Save reply" +searchPlaceholder = "Search comments" title = "Comments" typeComment = "Comment" typeInsertText = "Insert Text" @@ -11381,6 +11386,7 @@ unsavedDesc = "You have unsaved changes" closeSidebar = "Close layers sidebar" hideAll = "Hide all layers" showAll = "Show all layers" +title = "Layers" [viewer.link] delete = "Delete link" diff --git a/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx b/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx index 4dcc61fe2b..fe0bd8414b 100644 --- a/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, useCallback } from "react"; -import { Box, ScrollArea, Text, Loader, Stack, TextInput } from "@mantine/core"; +import { Text, Loader, Stack } from "@mantine/core"; import LocalIcon from "@app/components/shared/LocalIcon"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; @@ -9,7 +9,7 @@ import { PdfAttachmentObject } from "@embedpdf/models"; import AttachmentIcon from "@mui/icons-material/AttachmentRounded"; import DownloadIcon from "@mui/icons-material/DownloadRounded"; import { useTranslation } from "react-i18next"; -import "@app/components/viewer/SidebarBase.css"; +import { SidebarBase } from "@app/components/viewer/SidebarBase"; import "@app/components/viewer/AttachmentSidebar.css"; interface AttachmentSidebarProps { @@ -20,8 +20,6 @@ interface AttachmentSidebarProps { preloadCacheKeys?: string[]; } -const SIDEBAR_WIDTH = "15rem"; - interface AttachmentCacheEntry { status: "idle" | "loading" | "success" | "error"; attachments: PdfAttachmentObject[] | null; @@ -360,179 +358,129 @@ export const AttachmentSidebar = ({ const showNoDocument = attachmentSupport && !documentCacheKey; return ( - } + rightOffset={`${(thumbnailVisible ? 15 : 0) + (bookmarkVisible ? 15 : 0)}rem`} + visible={visible} + onClose={toggleAttachmentSidebar} + closeLabel={t( + "viewer.attachments.closeSidebar", + "Close attachments sidebar", + )} + searchTerm={searchTerm} + searchPlaceholder={t( + "viewer.attachments.searchPlaceholder", + "Search attachments", + )} + onSearchChange={setSearchTerm} > -
-
- - - - - {t("viewer.attachments.title", "Attachments")} + {!attachmentSupport && ( +
+ + {t( + "viewer.attachments.noSupport", + "Attachment support is unavailable for this viewer.", + )}
- - + + {t( + "viewer.attachments.noDocument", + "Open a PDF to view its attachments.", )} - title={t("viewer.attachments.close", "Close attachments")} + +
+ )} + + {attachmentSupport && documentCacheKey && currentError && ( + + + {currentError} + + - + - -
+ + )} - - setSearchTerm(event.currentTarget.value)} - leftSection={ - - } - size="xs" - /> - - - - - {!attachmentSupport && ( -
- - {t( - "viewer.attachments.noSupport", - "Attachment support is unavailable for this viewer.", - )} - -
- )} + + + {t("viewer.attachments.loading", "Loading attachments...")} + + + )} - {attachmentSupport && showNoDocument && ( -
- - {t( - "viewer.attachments.noDocument", - "Open a PDF to view its attachments.", - )} - -
- )} + {showEmptyState && ( + + + + {t("viewer.attachments.empty", "No attachments in this document")} + + + + )} - {attachmentSupport && documentCacheKey && currentError && ( - - - {currentError} - - - - - - )} + {showAttachmentList && ( + <> + +
+ {renderAttachments(filteredAttachments)} +
+ + )} - {attachmentSupport && documentCacheKey && isLocalLoading && ( - - - - {t("viewer.attachments.loading", "Loading attachments...")} - - - )} - - {showEmptyState && ( - - - - {t( - "viewer.attachments.empty", - "No attachments in this document", - )} - - - - )} - - {showAttachmentList && ( - <> - -
- {renderAttachments(filteredAttachments)} -
- - )} - - {showSearchEmpty && ( -
- - {t( - "viewer.attachments.noMatch", - "No attachments match your search", - )} - -
- )} -
-
-
+ {showSearchEmpty && ( +
+ + {t( + "viewer.attachments.noMatch", + "No attachments match your search", + )} + +
+ )} + ); }; diff --git a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx index 2acd3e309d..f331ab5176 100644 --- a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { Box, - ScrollArea, Text, Loader, Stack, @@ -21,7 +20,7 @@ import apiClient from "@app/services/apiClient"; import { PdfBookmarkObject, PdfActionType } from "@embedpdf/models"; import { useTranslation } from "react-i18next"; import BookmarksIcon from "@mui/icons-material/BookmarksRounded"; -import "@app/components/viewer/SidebarBase.css"; +import { SidebarBase } from "@app/components/viewer/SidebarBase"; import "@app/components/viewer/BookmarkSidebar.css"; interface BookmarkSidebarProps { @@ -31,8 +30,6 @@ interface BookmarkSidebarProps { preloadCacheKeys?: string[]; } -const SIDEBAR_WIDTH = "15rem"; - type BookmarkNode = PdfBookmarkObject & { id: string }; type BookmarkCacheStatus = "idle" | "loading" | "success" | "error"; @@ -666,285 +663,208 @@ export const BookmarkSidebar = ({ if (!visible) { return null; } - - return ( - -
-
- - - - - Bookmarks - -
- - {bookmarkSupport && bookmarksWithIds.length > 0 && ( - <> - {Object.values(expanded).some((val) => val === false) ? ( - - - - ) : ( - - - - )} - - )} + const expandCollapseActions = + bookmarkSupport && bookmarksWithIds.length > 0 ? ( + <> + {Object.values(expanded).some((val) => val === false) ? ( - + - -
+ ) : ( + + + + )} + + ) : null; - - setSearchTerm(event.currentTarget.value)} - leftSection={ - - } - size="xs" - /> - + return ( + } + rightOffset={`${thumbnailVisible ? 15 : 0}rem`} + visible={visible} + onClose={toggleBookmarkSidebar} + closeLabel={t("viewer.bookmarks.closeSidebar", "Close bookmarks sidebar")} + headerActions={expandCollapseActions} + searchTerm={searchTerm} + searchPlaceholder={t( + "viewer.bookmarks.searchPlaceholder", + "Search bookmarks", + )} + onSearchChange={setSearchTerm} + > + {bookmarkSupport && showNoDocument && ( +
+ + Open a PDF to view its bookmarks. + +
+ )} - - - {!bookmarkSupport && ( -
- - Bookmark support is unavailable for this viewer. - -
- )} + {bookmarkSupport && documentCacheKey && currentError && ( + + + {currentError} + + + + )} - {bookmarkSupport && showNoDocument && ( -
- - Open a PDF to view its bookmarks. - -
- )} + {bookmarkSupport && documentCacheKey && isLocalLoading && ( + + + + Loading bookmarks... + + + )} + {showEmptyState && !isAddingBookmark && ( + + + + {t("viewer.bookmarks.empty", "No bookmarks in this document")} + + + + )} - {bookmarkSupport && documentCacheKey && currentError && ( - - - {currentError} - - - - )} - - {bookmarkSupport && documentCacheKey && isLocalLoading && ( - - - - Loading bookmarks... - - - )} - {showEmptyState && !isAddingBookmark && ( - - - - No bookmarks in this document - - - - )} - - {isAddingBookmark && ( - - - - Add bookmark - - setNewBookmarkTitle(e.currentTarget.value)} - autoFocus - disabled={isSavingBookmark} - /> - - setNewBookmarkPage(typeof v === "number" ? v : 1) - } - disabled={isSavingBookmark} - /> - {addBookmarkError && ( - - {addBookmarkError} - - )} - - - - - - - )} - {showBookmarkList && ( - <> - {!isAddingBookmark && ( - + {isAddingBookmark && ( + + + + {t("viewer.bookmarks.addBookmark", "Add bookmark")} + + - {renderBookmarks(filteredBookmarks)} - - - )} - - {showSearchEmpty && ( -
- - No bookmarks match your search + aria-label={t("viewer.bookmarks.bookmarkTitle", "Bookmark title")} + value={newBookmarkTitle} + onChange={(e) => setNewBookmarkTitle(e.currentTarget.value)} + autoFocus + disabled={isSavingBookmark} + /> + + setNewBookmarkPage(typeof v === "number" ? v : 1) + } + disabled={isSavingBookmark} + /> + {addBookmarkError && ( + + {addBookmarkError} -
- )} + )} + + + + +
-
+ )} + {showBookmarkList && ( + <> + {!isAddingBookmark && ( + + )} +
+ {renderBookmarks(filteredBookmarks)} +
+ + )} + {showSearchEmpty && ( +
+ + No bookmarks match your search + +
+ )} {bookmarkSupport && documentCacheKey && ( )} -
+ ); }; diff --git a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx index 3bbc5c8f53..d2ca4e9436 100644 --- a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx @@ -1,7 +1,6 @@ import { useMemo, useState, useCallback, useEffect, useRef } from "react"; import { Box, - ScrollArea, Text, Textarea, Stack, @@ -36,8 +35,7 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useAnnotation as useAnnotationContext } from "@app/contexts/AnnotationContext"; import LocalIcon from "@app/components/shared/LocalIcon"; import { compareEntriesByVisualOrder } from "@app/components/viewer/commentsSidebarOrder"; - -const SIDEBAR_WIDTH = "18rem"; +import { SidebarBase } from "@app/components/viewer/SidebarBase"; /** PDF subtypes that are inherently standalone comment annotations (not linked to other annotations). */ const STANDALONE_COMMENT_SUBTYPES = new Set([ @@ -455,18 +453,60 @@ export function CommentsSidebar({ return ids; }, [state]); + const [searchTerm, setSearchTerm] = useState(""); + + const filteredByPage = useMemo(() => { + const query = searchTerm.trim().toLowerCase(); + if (!query) { + return byPage; + } + const result: Record = {}; + for (const [pageStr, entries] of Object.entries(byPage)) { + const matching = entries.filter((entry) => { + const ann = entry.annotation.object; + const contents = (ann.contents || "").toLowerCase(); + const author = (ann.author || "").toLowerCase(); + const replies = entry.replies || []; + const replyMatch = replies.some( + (r) => + (r.object.contents || "").toLowerCase().includes(query) || + (r.object.author || "").toLowerCase().includes(query), + ); + return contents.includes(query) || author.includes(query) || replyMatch; + }); + if (matching.length > 0) { + result[Number(pageStr)] = matching; + } + } + return result; + }, [byPage, searchTerm]); + const pageNumbers = useMemo( + () => + Object.keys(filteredByPage) + .map(Number) + .sort((a, b) => a - b), + [filteredByPage], + ); + + const totalCount = useMemo( () => Object.keys(byPage) .map(Number) - .sort((a, b) => a - b), + .reduce((sum, p) => sum + (byPage[p]?.length ?? 0), 0), [byPage], ); - const totalCount = useMemo( - () => pageNumbers.reduce((sum, p) => sum + (byPage[p]?.length ?? 0), 0), - [pageNumbers, byPage], + + const totalFilteredCount = useMemo( + () => + pageNumbers.reduce((sum, p) => sum + (filteredByPage[p]?.length ?? 0), 0), + [pageNumbers, filteredByPage], ); + const isSearchActive = searchTerm.trim().length > 0; + const showSearchEmpty = + isSearchActive && totalCount > 0 && totalFilteredCount === 0; + const handleContentsChange = useCallback( (pageIndex: number, annotationId: string, value: string) => { setDraftContents((prev) => ({ @@ -670,189 +710,165 @@ export function CommentsSidebar({ if (!visible) return null; - return ( - -
- - - {t("viewer.comments.title", "Comments")} - - {totalCount > 0 && ( - - - - - - - - - - - - - - - - } - color="red" - onClick={() => setClearAllModalOpen(true)} - > - {t("viewer.comments.clearAll", "Clear all comments")} - - - - - )} - {toggleCommentsSidebar && ( + const commentsHeaderActions = + totalCount > 0 ? ( + + - + + + + + + + + + + + + } + color="red" + onClick={() => setClearAllModalOpen(true)} + > + {t("viewer.comments.clearAll", "Clear all comments")} + + + + + ) : null; + + return ( + <> + } + rightOffset={rightOffset} + visible={visible} + onClose={toggleCommentsSidebar} + closeLabel={t("viewer.comments.closeSidebar", "Close comments sidebar")} + headerActions={commentsHeaderActions} + searchTerm={searchTerm} + searchPlaceholder={t( + "viewer.comments.searchPlaceholder", + "Search comments", )} -
- - - {totalCount === 0 ? ( - - - + onSearchChange={setSearchTerm} + viewportRef={scrollViewportRef} + > + {totalCount === 0 ? ( + + + + {t( + "viewer.comments.hint", + "Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page.", + )} + + {isPlacingComment ? ( + + ) : ( + + )} + + ) : ( + <> + {isPlacingComment ? ( + + ) : ( + + )} + {showSearchEmpty ? ( +
+ {t( - "viewer.comments.placingHint", - "Click a page to place… (cancel)", + "viewer.comments.noMatch", + "No comments match your search", )} - - ) : ( - - )} - - ) : ( - <> - {isPlacingComment ? ( - - ) : ( - - )} - {pageNumbers.map((pageIndex) => { - const entries = byPage[pageIndex] ?? []; + +
+ ) : ( + pageNumbers.map((pageIndex) => { + const entries = filteredByPage[pageIndex] ?? []; const pageNum = pageIndex + 1; return ( @@ -1291,11 +1307,11 @@ export function CommentsSidebar({
); - })} - - )} - - + }) + )} + + )} + - + ); } diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index 39d751790f..c56d86662c 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -1132,7 +1132,7 @@ const EmbedPdfViewerContent = ({ ]); const sidebarWidthRem = 15; - const commentsSidebarWidthRem = 18; + const commentsSidebarWidthRem = 15; const totalRightMargin = (isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0) + diff --git a/frontend/editor/src/core/components/viewer/LayerSidebar.tsx b/frontend/editor/src/core/components/viewer/LayerSidebar.tsx index 533bb7959b..b7fa108360 100644 --- a/frontend/editor/src/core/components/viewer/LayerSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/LayerSidebar.tsx @@ -1,21 +1,12 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import { - Box, - ScrollArea, - Text, - Checkbox, - Stack, - Loader, - Tooltip, -} from "@mantine/core"; +import { Text, Checkbox, Stack, Loader, Tooltip } from "@mantine/core"; import LayersIcon from "@mui/icons-material/Layers"; import { ActionIcon } from "@app/ui/ActionIcon"; import VisibilityIcon from "@mui/icons-material/Visibility"; import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; -import LocalIcon from "@app/components/shared/LocalIcon"; import { useTranslation } from "react-i18next"; import { useViewer } from "@app/contexts/ViewerContext"; -import "@app/components/viewer/SidebarBase.css"; +import { SidebarBase } from "@app/components/viewer/SidebarBase"; import "@app/components/viewer/LayerSidebar.css"; import { readPdfLayers, @@ -40,8 +31,6 @@ interface LayerSidebarProps { onLayersDetected?: (hasLayers: boolean) => void; } -const SIDEBAR_WIDTH = "15rem"; - type LoadStatus = "idle" | "loading" | "ready" | "no-layers" | "error"; export function LayerSidebar({ @@ -318,116 +307,91 @@ export function LayerSidebar({ const allVisible = leafIds.every((id) => visibility[id] !== false); const allHidden = leafIds.every((id) => visibility[id] === false); - return ( - - {/* Header */} -
-
- - - - - Layers - - {isApplying && } -
-
- {status === "ready" && leafIds.length > 0 && ( - <> - - - - - - - - )} + const layerHeaderActions = ( + <> + {isApplying && } + {status === "ready" && leafIds.length > 0 && ( + <> - + + + + + + )} + + ); + + return ( + } + rightOffset={`${rightOffset}rem`} + visible={visible} + onClose={toggleLayerSidebar} + closeLabel={t("viewer.layers.closeSidebar", "Close layers sidebar")} + headerActions={layerHeaderActions} + > + {status === "idle" && ( +
+ + Open a PDF to view its layers. +
-
+ )} - {/* Content */} - - - {status === "idle" && ( -
- - Open a PDF to view its layers. - -
- )} + {status === "loading" && ( + + + + Loading layers... + + + )} - {status === "loading" && ( - - - - Loading layers... - - - )} + {status === "error" && ( +
+ + {loadError ?? "Failed to load layers."} + +
+ )} - {status === "error" && ( -
- - {loadError ?? "Failed to load layers."} - -
- )} + {status === "no-layers" && ( +
+ + This document has no layers. + +
+ )} - {status === "no-layers" && ( -
- - This document has no layers. - -
- )} - - {status === "ready" && layers.length > 0 && ( -
- {layers.map((layer) => renderLayer({ ...layer, depth: 0 }))} -
- )} -
-
- + {status === "ready" && layers.length > 0 && ( +
+ {layers.map((layer) => renderLayer({ ...layer, depth: 0 }))} +
+ )} + ); } diff --git a/frontend/editor/src/core/components/viewer/SidebarBase.tsx b/frontend/editor/src/core/components/viewer/SidebarBase.tsx new file mode 100644 index 0000000000..7cdcc34d97 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/SidebarBase.tsx @@ -0,0 +1,121 @@ +import type { ReactNode } from "react"; +import { Box, ScrollArea, Text, TextInput } from "@mantine/core"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import "@app/components/viewer/SidebarBase.css"; + +export const SIDEBAR_WIDTH = "15rem"; + +export interface SidebarBaseProps { + /** Sidebar title string or React element. */ + title: ReactNode; + /** Header icon (ReactNode or string icon name for LocalIcon). */ + icon: ReactNode; + /** Right offset position string (e.g. "15rem" or "0rem"). */ + rightOffset?: string; + /** Sidebar visibility flag. */ + visible?: boolean; + /** Additional CSS class names. */ + className?: string; + /** Callback fired when user clicks the header close button. */ + onClose?: () => void; + /** Accessible label for the close button. */ + closeLabel?: string; + /** Extra buttons/elements to render in the header right actions area. */ + headerActions?: ReactNode; + /** Current search input term. */ + searchTerm?: string; + /** Search input placeholder text. */ + searchPlaceholder?: string; + /** Callback fired when search query changes. */ + onSearchChange?: (value: string) => void; + /** Optional ref for the ScrollArea viewport element. */ + viewportRef?: React.Ref; + /** Sidebar content children. */ + children: ReactNode; +} + +export function SidebarBase({ + title, + icon, + rightOffset = "0rem", + visible = true, + className = "", + onClose, + closeLabel = "Close sidebar", + headerActions, + searchTerm, + searchPlaceholder, + onSearchChange, + viewportRef, + children, +}: SidebarBaseProps) { + if (!visible) { + return null; + } + + const renderIcon = + typeof icon === "string" ? ( + + ) : ( + icon + ); + + return ( + +
+
+ {renderIcon} + + {title} + +
+ + {headerActions} + {onClose && ( + + + + )} + +
+ + {onSearchChange !== undefined && ( + + onSearchChange(e.currentTarget.value)} + leftSection={ + + } + size="xs" + /> + + )} + + + + {children} + + +
+ ); +}