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:
brios
2026-08-11 14:17:36 +00:00
committed by GitHub
parent 8b1bfb87f7
commit b56e62caec
7 changed files with 709 additions and 734 deletions
@@ -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>
);
}