mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
refactor(sidebar): Replace custom sidebars (bookmark, attachment etc...) with reusable sidebar component (#7143)
# Description of Changes This helps keeps them consistent and similar/same in regards to design, colour scheme and so on. Generally, we would want to improve upon this, as UX bit lacking here and there e.g., with the button fluidness/conssitency what the button does. Bookmark open sidebar editor, the other two redirects to the page, those may be made more consistent. Changes: * Refactored `AttachmentSidebar`, `BookmarkSidebar`, and `CommentsSidebar` to use the new `SidebarBase` component, replacing custom header, search, and layout code for a more consistent and maintainable UI. * Added search functionality to `CommentsSidebar`, including filtering by comment content and author, and displaying a message when no comments match the search. * Updated search placeholders and empty state messages for attachments, bookmarks, and comments to use translation keys, improving localization and user feedback. ### New <img width="1490" height="1816" alt="image" src="https://github.com/user-attachments/assets/71de659e-388e-4aef-bf03-4c5ee7948a2f" /> ### Old <img width="1574" height="1726" alt="image" src="https://github.com/user-attachments/assets/4604653c-865f-4e75-9737-4b61237efcc6" /> <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
className="sidebar-base attachment-sidebar"
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: `${(thumbnailVisible ? 15 : 0) + (bookmarkVisible ? 15 : 0)}rem`,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: SIDEBAR_WIDTH,
|
||||
zIndex: 998,
|
||||
}}
|
||||
<SidebarBase
|
||||
className="attachment-sidebar"
|
||||
title={t("viewer.attachments.title", "Attachments")}
|
||||
icon={<AttachmentIcon />}
|
||||
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}
|
||||
>
|
||||
<div className="sidebar-base__header attachment-sidebar__header">
|
||||
<div className="sidebar-base__header-title attachment-sidebar__header-title">
|
||||
<span className="sidebar-base__header-icon attachment-sidebar__header-icon">
|
||||
<AttachmentIcon />
|
||||
</span>
|
||||
<Text fw={600} size="sm" tt="uppercase" lts={0.5}>
|
||||
{t("viewer.attachments.title", "Attachments")}
|
||||
{!attachmentSupport && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
"viewer.attachments.noSupport",
|
||||
"Attachment support is unavailable for this viewer.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Box style={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={toggleAttachmentSidebar}
|
||||
aria-label={t(
|
||||
"viewer.attachments.closeSidebar",
|
||||
"Close attachments sidebar",
|
||||
)}
|
||||
|
||||
{attachmentSupport && showNoDocument && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
"viewer.attachments.noDocument",
|
||||
"Open a PDF to view its attachments.",
|
||||
)}
|
||||
title={t("viewer.attachments.close", "Close attachments")}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachmentSupport && documentCacheKey && currentError && (
|
||||
<Stack gap="xs" align="center" className="sidebar-base__error">
|
||||
<Text size="sm" c="red" ta="center">
|
||||
{currentError}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="secondary"
|
||||
aria-label={t("viewer.attachments.retry", "Retry")}
|
||||
onClick={requestReload}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
|
||||
<LocalIcon icon="refresh" />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Box
|
||||
px="sm"
|
||||
pb="sm"
|
||||
className="sidebar-base__search attachment-sidebar__search"
|
||||
>
|
||||
<TextInput
|
||||
value={searchTerm}
|
||||
placeholder={t(
|
||||
"viewer.attachments.searchPlaceholder",
|
||||
"Search attachments",
|
||||
)}
|
||||
onChange={(event) => setSearchTerm(event.currentTarget.value)}
|
||||
leftSection={
|
||||
<LocalIcon icon="search" width="1.1rem" height="1.1rem" />
|
||||
}
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Box
|
||||
p="sm"
|
||||
className="sidebar-base__content attachment-sidebar__content"
|
||||
{attachmentSupport && documentCacheKey && isLocalLoading && (
|
||||
<Stack
|
||||
gap="md"
|
||||
align="center"
|
||||
c="dimmed"
|
||||
py="xl"
|
||||
className="sidebar-base__loading"
|
||||
>
|
||||
{!attachmentSupport && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
"viewer.attachments.noSupport",
|
||||
"Attachment support is unavailable for this viewer.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<Loader size="md" type="dots" />
|
||||
<Text size="sm" ta="center">
|
||||
{t("viewer.attachments.loading", "Loading attachments...")}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{attachmentSupport && showNoDocument && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
"viewer.attachments.noDocument",
|
||||
"Open a PDF to view its attachments.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{showEmptyState && (
|
||||
<Stack align="center" gap="sm" py="lg">
|
||||
<LocalIcon
|
||||
icon="attachment-rounded"
|
||||
width="2rem"
|
||||
height="2rem"
|
||||
style={{ color: "var(--mantine-color-dimmed)" }}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("viewer.attachments.empty", "No attachments in this document")}
|
||||
</Text>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={handleAddAttachment}
|
||||
leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />}
|
||||
>
|
||||
{t("viewer.attachments.addAttachment", "Add attachment")}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{attachmentSupport && documentCacheKey && currentError && (
|
||||
<Stack gap="xs" align="center" className="sidebar-base__error">
|
||||
<Text size="sm" c="red" ta="center">
|
||||
{currentError}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="secondary"
|
||||
aria-label={t("viewer.attachments.retry", "Retry")}
|
||||
onClick={requestReload}
|
||||
>
|
||||
<LocalIcon icon="refresh" />
|
||||
</ActionIcon>
|
||||
</Stack>
|
||||
)}
|
||||
{showAttachmentList && (
|
||||
<>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
justify="start"
|
||||
onClick={handleAddAttachment}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="0.9rem" height="0.9rem" />
|
||||
}
|
||||
style={{ marginBottom: "var(--space-xs)" }}
|
||||
>
|
||||
{t("viewer.attachments.addAttachment", "Add attachment")}
|
||||
</Button>
|
||||
<div className="attachment-list">
|
||||
{renderAttachments(filteredAttachments)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{attachmentSupport && documentCacheKey && isLocalLoading && (
|
||||
<Stack
|
||||
gap="md"
|
||||
align="center"
|
||||
c="dimmed"
|
||||
py="xl"
|
||||
className="sidebar-base__loading"
|
||||
>
|
||||
<Loader size="md" type="dots" />
|
||||
<Text size="sm" ta="center">
|
||||
{t("viewer.attachments.loading", "Loading attachments...")}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{showEmptyState && (
|
||||
<Stack align="center" gap="sm" py="lg">
|
||||
<LocalIcon
|
||||
icon="attachment-rounded"
|
||||
width="2rem"
|
||||
height="2rem"
|
||||
style={{ color: "var(--mantine-color-dimmed)" }}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
"viewer.attachments.empty",
|
||||
"No attachments in this document",
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleAddAttachment}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="1rem" height="1rem" />
|
||||
}
|
||||
>
|
||||
{t("viewer.attachments.addAttachment", "Add attachment")}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{showAttachmentList && (
|
||||
<>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
justify="start"
|
||||
onClick={handleAddAttachment}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="0.9rem" height="0.9rem" />
|
||||
}
|
||||
style={{ marginBottom: "var(--space-xs)" }}
|
||||
>
|
||||
{t("viewer.attachments.addAttachment", "Add attachment")}
|
||||
</Button>
|
||||
<div className="attachment-list">
|
||||
{renderAttachments(filteredAttachments)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showSearchEmpty && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
"viewer.attachments.noMatch",
|
||||
"No attachments match your search",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
{showSearchEmpty && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
"viewer.attachments.noMatch",
|
||||
"No attachments match your search",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</SidebarBase>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
className="sidebar-base bookmark-sidebar"
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: thumbnailVisible ? SIDEBAR_WIDTH : 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: SIDEBAR_WIDTH,
|
||||
zIndex: 998,
|
||||
}}
|
||||
>
|
||||
<div className="sidebar-base__header bookmark-sidebar__header">
|
||||
<div className="sidebar-base__header-title bookmark-sidebar__header-title">
|
||||
<span className="sidebar-base__header-icon bookmark-sidebar__header-icon">
|
||||
<BookmarksIcon />
|
||||
</span>
|
||||
<Text fw={600} size="sm" tt="uppercase" lts={0.5}>
|
||||
Bookmarks
|
||||
</Text>
|
||||
</div>
|
||||
<Box style={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
{bookmarkSupport && bookmarksWithIds.length > 0 && (
|
||||
<>
|
||||
{Object.values(expanded).some((val) => val === false) ? (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={expandAll}
|
||||
aria-label={t(
|
||||
"viewer.bookmarks.expandAll",
|
||||
"Expand all bookmarks",
|
||||
)}
|
||||
title={t(
|
||||
"viewer.bookmarks.expandAll",
|
||||
"Expand all bookmarks",
|
||||
)}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="unfold-more"
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={collapseAll}
|
||||
aria-label={t(
|
||||
"viewer.bookmarks.collapseAll",
|
||||
"Collapse all bookmarks",
|
||||
)}
|
||||
title={t(
|
||||
"viewer.bookmarks.collapseAll",
|
||||
"Collapse all bookmarks",
|
||||
)}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="unfold-less"
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
const expandCollapseActions =
|
||||
bookmarkSupport && bookmarksWithIds.length > 0 ? (
|
||||
<>
|
||||
{Object.values(expanded).some((val) => val === false) ? (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={toggleBookmarkSidebar}
|
||||
aria-label={t(
|
||||
"viewer.bookmarks.closeSidebar",
|
||||
"Close bookmarks sidebar",
|
||||
)}
|
||||
title={t(
|
||||
"viewer.bookmarks.closeSidebar",
|
||||
"Close bookmarks sidebar",
|
||||
)}
|
||||
onClick={expandAll}
|
||||
aria-label={t("viewer.bookmarks.expandAll", "Expand all bookmarks")}
|
||||
title={t("viewer.bookmarks.expandAll", "Expand all bookmarks")}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
|
||||
<LocalIcon icon="unfold-more" width="1.1rem" height="1.1rem" />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
</div>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={collapseAll}
|
||||
aria-label={t(
|
||||
"viewer.bookmarks.collapseAll",
|
||||
"Collapse all bookmarks",
|
||||
)}
|
||||
title={t("viewer.bookmarks.collapseAll", "Collapse all bookmarks")}
|
||||
>
|
||||
<LocalIcon icon="unfold-less" width="1.1rem" height="1.1rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
<Box
|
||||
px="sm"
|
||||
pb="sm"
|
||||
className="sidebar-base__search bookmark-sidebar__search"
|
||||
>
|
||||
<TextInput
|
||||
value={searchTerm}
|
||||
placeholder={t(
|
||||
"viewer.bookmarks.searchPlaceholder",
|
||||
"Search bookmarks",
|
||||
)}
|
||||
onChange={(event) => setSearchTerm(event.currentTarget.value)}
|
||||
leftSection={
|
||||
<LocalIcon icon="search" width="1.1rem" height="1.1rem" />
|
||||
}
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
return (
|
||||
<SidebarBase
|
||||
className="bookmark-sidebar"
|
||||
title={t("viewer.bookmarks.title", "Bookmarks")}
|
||||
icon={<BookmarksIcon />}
|
||||
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 && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Open a PDF to view its bookmarks.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Box p="sm" className="sidebar-base__content bookmark-sidebar__content">
|
||||
{!bookmarkSupport && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Bookmark support is unavailable for this viewer.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{bookmarkSupport && documentCacheKey && currentError && (
|
||||
<Stack gap="xs" align="center" className="sidebar-base__error">
|
||||
<Text size="sm" c="red" ta="center">
|
||||
{currentError}
|
||||
</Text>
|
||||
<Button variant="secondary" size="sm" onClick={requestReload}>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{bookmarkSupport && showNoDocument && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Open a PDF to view its bookmarks.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{bookmarkSupport && documentCacheKey && isLocalLoading && (
|
||||
<Stack
|
||||
gap="md"
|
||||
align="center"
|
||||
c="dimmed"
|
||||
py="xl"
|
||||
className="sidebar-base__loading"
|
||||
>
|
||||
<Loader size="md" type="dots" />
|
||||
<Text size="sm" ta="center">
|
||||
Loading bookmarks...
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{showEmptyState && !isAddingBookmark && (
|
||||
<Stack align="center" gap="sm" py="lg">
|
||||
<LocalIcon
|
||||
icon="bookmark-add-rounded"
|
||||
width="2rem"
|
||||
height="2rem"
|
||||
style={{ color: "var(--mantine-color-dimmed)" }}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("viewer.bookmarks.empty", "No bookmarks in this document")}
|
||||
</Text>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={handleOpenAddBookmark}
|
||||
leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />}
|
||||
>
|
||||
{t("viewer.bookmarks.addBookmark", "Add bookmark")}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{bookmarkSupport && documentCacheKey && currentError && (
|
||||
<Stack gap="xs" align="center" className="sidebar-base__error">
|
||||
<Text size="sm" c="red" ta="center">
|
||||
{currentError}
|
||||
</Text>
|
||||
<Button variant="secondary" size="sm" onClick={requestReload}>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{bookmarkSupport && documentCacheKey && isLocalLoading && (
|
||||
<Stack
|
||||
gap="md"
|
||||
align="center"
|
||||
c="dimmed"
|
||||
py="xl"
|
||||
className="sidebar-base__loading"
|
||||
>
|
||||
<Loader size="md" type="dots" />
|
||||
<Text size="sm" ta="center">
|
||||
Loading bookmarks...
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{showEmptyState && !isAddingBookmark && (
|
||||
<Stack align="center" gap="sm" py="lg">
|
||||
<LocalIcon
|
||||
icon="bookmark-add-rounded"
|
||||
width="2rem"
|
||||
height="2rem"
|
||||
style={{ color: "var(--mantine-color-dimmed)" }}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No bookmarks in this document
|
||||
</Text>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={handleOpenAddBookmark}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="1rem" height="1rem" />
|
||||
}
|
||||
>
|
||||
Add bookmark
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{isAddingBookmark && (
|
||||
<Box
|
||||
mb="sm"
|
||||
p="sm"
|
||||
data-testid="bookmark-add-form"
|
||||
style={{
|
||||
border: "1px solid var(--c-border-subtle)",
|
||||
borderRadius: 6,
|
||||
background:
|
||||
"var(--c-surface-raised, var(--mantine-color-gray-0))",
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
||||
Add bookmark
|
||||
</Text>
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t(
|
||||
"viewer.bookmarks.bookmarkTitle",
|
||||
"Bookmark title",
|
||||
)}
|
||||
aria-label={t(
|
||||
"viewer.bookmarks.bookmarkTitle",
|
||||
"Bookmark title",
|
||||
)}
|
||||
value={newBookmarkTitle}
|
||||
onChange={(e) => setNewBookmarkTitle(e.currentTarget.value)}
|
||||
autoFocus
|
||||
disabled={isSavingBookmark}
|
||||
/>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
label="Page"
|
||||
min={1}
|
||||
clampBehavior="strict"
|
||||
value={newBookmarkPage}
|
||||
onChange={(v) =>
|
||||
setNewBookmarkPage(typeof v === "number" ? v : 1)
|
||||
}
|
||||
disabled={isSavingBookmark}
|
||||
/>
|
||||
{addBookmarkError && (
|
||||
<Text size="xs" c="red">
|
||||
{addBookmarkError}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleCancelAddBookmark}
|
||||
disabled={isSavingBookmark}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={handleSubmitAddBookmark}
|
||||
loading={isSavingBookmark}
|
||||
disabled={!newBookmarkTitle.trim()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
{showBookmarkList && (
|
||||
<>
|
||||
{!isAddingBookmark && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
justify="start"
|
||||
onClick={handleOpenAddBookmark}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="0.9rem" height="0.9rem" />
|
||||
}
|
||||
style={{ marginBottom: "var(--space-xs)" }}
|
||||
>
|
||||
Add bookmark
|
||||
</Button>
|
||||
{isAddingBookmark && (
|
||||
<Box
|
||||
mb="sm"
|
||||
p="sm"
|
||||
data-testid="bookmark-add-form"
|
||||
style={{
|
||||
border: "1px solid var(--c-border-subtle)",
|
||||
borderRadius: 6,
|
||||
background: "var(--c-surface-raised, var(--mantine-color-gray-0))",
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
||||
{t("viewer.bookmarks.addBookmark", "Add bookmark")}
|
||||
</Text>
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t(
|
||||
"viewer.bookmarks.bookmarkTitle",
|
||||
"Bookmark title",
|
||||
)}
|
||||
<div className="bookmark-list">
|
||||
{renderBookmarks(filteredBookmarks)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showSearchEmpty && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No bookmarks match your search
|
||||
aria-label={t("viewer.bookmarks.bookmarkTitle", "Bookmark title")}
|
||||
value={newBookmarkTitle}
|
||||
onChange={(e) => setNewBookmarkTitle(e.currentTarget.value)}
|
||||
autoFocus
|
||||
disabled={isSavingBookmark}
|
||||
/>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
label="Page"
|
||||
min={1}
|
||||
clampBehavior="strict"
|
||||
value={newBookmarkPage}
|
||||
onChange={(v) =>
|
||||
setNewBookmarkPage(typeof v === "number" ? v : 1)
|
||||
}
|
||||
disabled={isSavingBookmark}
|
||||
/>
|
||||
{addBookmarkError && (
|
||||
<Text size="xs" c="red">
|
||||
{addBookmarkError}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleCancelAddBookmark}
|
||||
disabled={isSavingBookmark}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={handleSubmitAddBookmark}
|
||||
loading={isSavingBookmark}
|
||||
disabled={!newBookmarkTitle.trim()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
)}
|
||||
{showBookmarkList && (
|
||||
<>
|
||||
{!isAddingBookmark && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
justify="start"
|
||||
onClick={handleOpenAddBookmark}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="0.9rem" height="0.9rem" />
|
||||
}
|
||||
style={{ marginBottom: "var(--space-xs)" }}
|
||||
>
|
||||
{t("viewer.bookmarks.addBookmark", "Add bookmark")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="bookmark-list">
|
||||
{renderBookmarks(filteredBookmarks)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showSearchEmpty && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No bookmarks match your search
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{bookmarkSupport && documentCacheKey && (
|
||||
<Box
|
||||
px="sm"
|
||||
py="xs"
|
||||
mt="sm"
|
||||
style={{
|
||||
borderTop: "1px solid var(--c-border-subtle)",
|
||||
backgroundColor: "var(--c-bg-raised)",
|
||||
@@ -978,6 +898,6 @@ export const BookmarkSidebar = ({
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</SidebarBase>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<number, SidebarAnnotationEntry[]> = {};
|
||||
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 (
|
||||
<Box
|
||||
ref={scrollViewportRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: rightOffset,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: SIDEBAR_WIDTH,
|
||||
backgroundColor: "var(--c-bg)",
|
||||
borderLeft: "1px solid var(--c-border-subtle)",
|
||||
zIndex: 998,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: "-2px 0 8px rgba(0, 0, 0, 0.1)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
borderBottom: "1px solid var(--c-border-subtle)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="comment"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
style={{ color: "var(--mantine-color-dimmed)", flexShrink: 0 }}
|
||||
/>
|
||||
<Text fw={600} size="sm" tt="uppercase" lts={0.5} style={{ flex: 1 }}>
|
||||
{t("viewer.comments.title", "Comments")}
|
||||
</Text>
|
||||
{totalCount > 0 && (
|
||||
<Group gap={2} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Tooltip label={t("viewer.comments.addComment", "Add comment")}>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
aria-label={t("viewer.comments.addComment", "Add comment")}
|
||||
onClick={handleAddComment}
|
||||
>
|
||||
<LocalIcon icon="add" width="1.25rem" height="1.25rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Menu position="bottom-end" withArrow>
|
||||
<Menu.Target>
|
||||
<Tooltip
|
||||
label={t("viewer.comments.moreActions", "More actions")}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
aria-label={t(
|
||||
"viewer.comments.moreActions",
|
||||
"More actions",
|
||||
)}
|
||||
>
|
||||
<MoreHorizIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<DeleteIcon style={{ fontSize: 18 }} />}
|
||||
color="red"
|
||||
onClick={() => setClearAllModalOpen(true)}
|
||||
>
|
||||
{t("viewer.comments.clearAll", "Clear all comments")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
)}
|
||||
{toggleCommentsSidebar && (
|
||||
const commentsHeaderActions =
|
||||
totalCount > 0 ? (
|
||||
<Group gap={2} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Tooltip label={t("viewer.comments.addComment", "Add comment")}>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={toggleCommentsSidebar}
|
||||
aria-label={t(
|
||||
"viewer.comments.closeSidebar",
|
||||
"Close comments sidebar",
|
||||
)}
|
||||
title={t("viewer.comments.close", "Close comments")}
|
||||
aria-label={t("viewer.comments.addComment", "Add comment")}
|
||||
onClick={handleAddComment}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
|
||||
<LocalIcon icon="add" width="1.25rem" height="1.25rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Menu position="bottom-end" withArrow>
|
||||
<Menu.Target>
|
||||
<Tooltip label={t("viewer.comments.moreActions", "More actions")}>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
aria-label={t("viewer.comments.moreActions", "More actions")}
|
||||
>
|
||||
<MoreHorizIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<DeleteIcon style={{ fontSize: 18 }} />}
|
||||
color="red"
|
||||
onClick={() => setClearAllModalOpen(true)}
|
||||
>
|
||||
{t("viewer.comments.clearAll", "Clear all comments")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarBase
|
||||
className="comments-sidebar"
|
||||
title={t("viewer.comments.title", "Comments")}
|
||||
icon={<LocalIcon icon="comment" width="1.1rem" height="1.1rem" />}
|
||||
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",
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Stack p="sm" gap="md">
|
||||
{totalCount === 0 ? (
|
||||
<Stack align="center" gap="sm" py="lg">
|
||||
<LocalIcon
|
||||
icon="comment"
|
||||
width="2rem"
|
||||
height="2rem"
|
||||
style={{ color: "var(--mantine-color-dimmed)" }}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
onSearchChange={setSearchTerm}
|
||||
viewportRef={scrollViewportRef}
|
||||
>
|
||||
{totalCount === 0 ? (
|
||||
<Stack align="center" gap="sm" py="lg">
|
||||
<LocalIcon
|
||||
icon="comment"
|
||||
width="2rem"
|
||||
height="2rem"
|
||||
style={{ color: "var(--mantine-color-dimmed)" }}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
"viewer.comments.hint",
|
||||
"Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page.",
|
||||
)}
|
||||
</Text>
|
||||
{isPlacingComment ? (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="warning"
|
||||
size="sm"
|
||||
onClick={handleCancelPlacingComment}
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="touch-app-rounded"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"viewer.comments.hint",
|
||||
"Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page.",
|
||||
"viewer.comments.placingHint",
|
||||
"Click a page to place… (cancel)",
|
||||
)}
|
||||
</Text>
|
||||
{isPlacingComment ? (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="warning"
|
||||
size="sm"
|
||||
onClick={handleCancelPlacingComment}
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="touch-app-rounded"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
}
|
||||
>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={handleAddComment}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="1rem" height="1rem" />
|
||||
}
|
||||
>
|
||||
{t("viewer.comments.addComment", "Add comment")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
{isPlacingComment ? (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="warning"
|
||||
size="sm"
|
||||
fullWidth
|
||||
justify="start"
|
||||
onClick={handleCancelPlacingComment}
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="touch-app-rounded"
|
||||
width="0.9rem"
|
||||
height="0.9rem"
|
||||
/>
|
||||
}
|
||||
style={{ paddingInline: 6 }}
|
||||
>
|
||||
{t(
|
||||
"viewer.comments.placingHint",
|
||||
"Click a page to place… (cancel)",
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
justify="start"
|
||||
onClick={handleAddComment}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="0.9rem" height="0.9rem" />
|
||||
}
|
||||
style={{
|
||||
paddingInline: 6,
|
||||
marginBottom: "var(--space-xs)",
|
||||
}}
|
||||
>
|
||||
{t("viewer.comments.addComment", "Add comment")}
|
||||
</Button>
|
||||
)}
|
||||
{showSearchEmpty ? (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
"viewer.comments.placingHint",
|
||||
"Click a page to place… (cancel)",
|
||||
"viewer.comments.noMatch",
|
||||
"No comments match your search",
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={handleAddComment}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="1rem" height="1rem" />
|
||||
}
|
||||
>
|
||||
{t("viewer.comments.addComment", "Add comment")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
{isPlacingComment ? (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="warning"
|
||||
size="sm"
|
||||
fullWidth
|
||||
justify="start"
|
||||
onClick={handleCancelPlacingComment}
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="touch-app-rounded"
|
||||
width="0.9rem"
|
||||
height="0.9rem"
|
||||
/>
|
||||
}
|
||||
style={{ paddingInline: 6 }}
|
||||
>
|
||||
{t(
|
||||
"viewer.comments.placingHint",
|
||||
"Click a page to place… (cancel)",
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
justify="start"
|
||||
onClick={handleAddComment}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="0.9rem" height="0.9rem" />
|
||||
}
|
||||
style={{ paddingInline: 6 }}
|
||||
>
|
||||
{t("viewer.comments.addComment", "Add comment")}
|
||||
</Button>
|
||||
)}
|
||||
{pageNumbers.map((pageIndex) => {
|
||||
const entries = byPage[pageIndex] ?? [];
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
pageNumbers.map((pageIndex) => {
|
||||
const entries = filteredByPage[pageIndex] ?? [];
|
||||
const pageNum = pageIndex + 1;
|
||||
return (
|
||||
<Box key={pageIndex} mb="md">
|
||||
@@ -1291,11 +1307,11 @@ export function CommentsSidebar({
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
})
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SidebarBase>
|
||||
|
||||
<Modal
|
||||
opened={!!deleteModal}
|
||||
@@ -1351,6 +1367,6 @@ export function CommentsSidebar({
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1132,7 +1132,7 @@ const EmbedPdfViewerContent = ({
|
||||
]);
|
||||
|
||||
const sidebarWidthRem = 15;
|
||||
const commentsSidebarWidthRem = 18;
|
||||
const commentsSidebarWidthRem = 15;
|
||||
const totalRightMargin =
|
||||
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) +
|
||||
(isBookmarkSidebarVisible ? sidebarWidthRem : 0) +
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
className="sidebar-base layer-sidebar"
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: `${rightOffset}rem`,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: SIDEBAR_WIDTH,
|
||||
zIndex: 998,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sidebar-base__header">
|
||||
<div className="sidebar-base__header-title">
|
||||
<span className="sidebar-base__header-icon">
|
||||
<LayersIcon fontSize="small" />
|
||||
</span>
|
||||
<Text fw={600} size="sm" tt="uppercase" lts={0.5} style={{ flex: 1 }}>
|
||||
Layers
|
||||
</Text>
|
||||
{isApplying && <Loader size="xs" type="dots" />}
|
||||
</div>
|
||||
<div className="layer-sidebar__header-actions">
|
||||
{status === "ready" && leafIds.length > 0 && (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={showAll}
|
||||
disabled={allVisible || isApplying}
|
||||
aria-label={t("viewer.layers.showAll", "Show all layers")}
|
||||
title={t("viewer.layers.showAll", "Show all layers")}
|
||||
>
|
||||
<VisibilityIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={hideAll}
|
||||
disabled={allHidden || isApplying}
|
||||
aria-label={t("viewer.layers.hideAll", "Hide all layers")}
|
||||
title={t("viewer.layers.hideAll", "Hide all layers")}
|
||||
>
|
||||
<VisibilityOffIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
const layerHeaderActions = (
|
||||
<>
|
||||
{isApplying && <Loader size="xs" type="dots" />}
|
||||
{status === "ready" && leafIds.length > 0 && (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={toggleLayerSidebar}
|
||||
aria-label={t("viewer.layers.closeSidebar", "Close layers sidebar")}
|
||||
title={t("viewer.layers.closeSidebar", "Close layers sidebar")}
|
||||
onClick={showAll}
|
||||
disabled={allVisible || isApplying}
|
||||
aria-label={t("viewer.layers.showAll", "Show all layers")}
|
||||
title={t("viewer.layers.showAll", "Show all layers")}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
|
||||
<VisibilityIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={hideAll}
|
||||
disabled={allHidden || isApplying}
|
||||
aria-label={t("viewer.layers.hideAll", "Hide all layers")}
|
||||
title={t("viewer.layers.hideAll", "Hide all layers")}
|
||||
>
|
||||
<VisibilityOffIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarBase
|
||||
className="layer-sidebar"
|
||||
title={t("viewer.layers.title", "Layers")}
|
||||
icon={<LayersIcon fontSize="small" />}
|
||||
rightOffset={`${rightOffset}rem`}
|
||||
visible={visible}
|
||||
onClose={toggleLayerSidebar}
|
||||
closeLabel={t("viewer.layers.closeSidebar", "Close layers sidebar")}
|
||||
headerActions={layerHeaderActions}
|
||||
>
|
||||
{status === "idle" && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Open a PDF to view its layers.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Box p="sm" className="sidebar-base__content">
|
||||
{status === "idle" && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Open a PDF to view its layers.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{status === "loading" && (
|
||||
<Stack
|
||||
gap="md"
|
||||
align="center"
|
||||
c="dimmed"
|
||||
py="xl"
|
||||
className="sidebar-base__loading"
|
||||
>
|
||||
<Loader size="md" type="dots" />
|
||||
<Text size="sm" ta="center">
|
||||
Loading layers...
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{status === "loading" && (
|
||||
<Stack
|
||||
gap="md"
|
||||
align="center"
|
||||
c="dimmed"
|
||||
py="xl"
|
||||
className="sidebar-base__loading"
|
||||
>
|
||||
<Loader size="md" type="dots" />
|
||||
<Text size="sm" ta="center">
|
||||
Loading layers...
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div className="sidebar-base__error">
|
||||
<Text size="sm" c="red" ta="center">
|
||||
{loadError ?? "Failed to load layers."}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="sidebar-base__error">
|
||||
<Text size="sm" c="red" ta="center">
|
||||
{loadError ?? "Failed to load layers."}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{status === "no-layers" && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
This document has no layers.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "no-layers" && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
This document has no layers.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && layers.length > 0 && (
|
||||
<div className="layer-list">
|
||||
{layers.map((layer) => renderLayer({ ...layer, depth: 0 }))}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
{status === "ready" && layers.length > 0 && (
|
||||
<div className="layer-list">
|
||||
{layers.map((layer) => renderLayer({ ...layer, depth: 0 }))}
|
||||
</div>
|
||||
)}
|
||||
</SidebarBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>;
|
||||
/** 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" ? (
|
||||
<LocalIcon icon={icon} width="1.1rem" height="1.1rem" />
|
||||
) : (
|
||||
icon
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={["sidebar-base", className].filter(Boolean).join(" ")}
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: rightOffset,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: SIDEBAR_WIDTH,
|
||||
zIndex: 998,
|
||||
}}
|
||||
>
|
||||
<div className="sidebar-base__header">
|
||||
<div className="sidebar-base__header-title">
|
||||
<span className="sidebar-base__header-icon">{renderIcon}</span>
|
||||
<Text fw={600} size="sm" tt="uppercase" lts={0.5}>
|
||||
{title}
|
||||
</Text>
|
||||
</div>
|
||||
<Box style={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
{headerActions}
|
||||
{onClose && (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
aria-label={closeLabel}
|
||||
title={closeLabel}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{onSearchChange !== undefined && (
|
||||
<Box px="sm" pb="sm" className="sidebar-base__search">
|
||||
<TextInput
|
||||
value={searchTerm ?? ""}
|
||||
placeholder={searchPlaceholder ?? "Search..."}
|
||||
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||
leftSection={
|
||||
<LocalIcon icon="search" width="1.1rem" height="1.1rem" />
|
||||
}
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} viewportRef={viewportRef}>
|
||||
<Box p="sm" className="sidebar-base__content">
|
||||
{children}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user