UX improvement for side menu bookmark, comments and attachments (#6552)

- Inline "Add bookmark" form in the bookmark sidebar (title + page,
defaults to current page) - saves via
/api/v1/general/edit-table-of-contents without leaving the viewer
- Persistent "+ Add" rows above the list in Bookmarks, Attachments,
Comments and Files sidebars (was only in empty state)
- Close (X) button in every viewer sidebar header (Bookmarks,
Attachments, Comments, Layers, Thumbnails)
- "Add comment" button morphs into "Click a page to place… (cancel)"
while textComment is armed, ESC to cancel
- "Add attachment" auto-closes the attachment sidebar so you don't end
up with two stacked panels
- Footer link in bookmark sidebar to the full Edit Table of Contents
tool for nesting/reordering
- Fix: bookmark/attachment sidebars getting stuck on "Loading…" after a
file swap (cache no longer caches `loading`, retry treats null bridge as
not-ready)
- Fix: Save silently routing to the editor tool on a fresh /read upload
when `activeFileId` is still null
- New Playwright tests (stubbed + live) covering Add buttons, Save flow
with PDF round-trip, and close buttons
<img width="720" height="1032" alt="06-thumbnails"
src="https://github.com/user-attachments/assets/62298d0d-8eba-4397-9bc2-96871be29b3c"
/>
<img width="790" height="1062" alt="01-bookmarks"
src="https://github.com/user-attachments/assets/1eb33667-c038-4b78-8711-97f354344fae"
/>
<img width="720" height="1032" alt="02-bookmarks-empty"
src="https://github.com/user-attachments/assets/3db263ef-9550-4bac-9ffa-c729263f42c3"
/>
<img width="1032" height="1032" alt="03-attachments"
src="https://github.com/user-attachments/assets/33580e64-020a-4e07-bf9a-595faf695fd8"
/>
<img width="919" height="1062" alt="04-comments"
src="https://github.com/user-attachments/assets/89ef01a8-35a6-406b-825a-f04beec02f29"
/>
<img width="720" height="1032" alt="05-layers"
src="https://github.com/user-attachments/assets/57d3cfe9-0a4c-468d-b497-ed855ddd69e5"
/>

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] 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)
- [ ] I have performed a self-review of my own code
- [ ] 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)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] 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:
Anthony Stirling
2026-06-29 14:05:58 +00:00
committed by GitHub
parent 14245d33d1
commit 5e97746721
12 changed files with 1528 additions and 511 deletions
@@ -8003,6 +8003,8 @@ zoomIn = "Zoom In"
zoomOut = "Zoom Out"
[viewer.attachments]
addAttachment = "Add attachment"
close = "Close attachments"
empty = "No attachments in this document"
loading = "Loading attachments..."
noDocument = "Open a PDF to view its attachments."
@@ -8016,6 +8018,7 @@ addComment = "Add comment"
addCommentPlaceholder = "Add comment..."
addLink = "Add link"
addReplyPlaceholder = "Add reply..."
close = "Close comments"
deleteAnnotationAndComment = "Delete annotation & comment"
deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything."
deleteTitle = "Remove annotation from comments?"
@@ -8026,6 +8029,7 @@ moreActions = "More actions"
nComments_one = "{{count}} comment"
nComments_other = "{{count}} comments"
pageLabel = "Page {{page}}"
placingHint = "Click a page to place… (cancel)"
removeCommentOnly = "Remove comment only"
saveReply = "Save reply"
title = "Comments"
@@ -8027,6 +8027,8 @@ zoomIn = "Zoom In"
zoomOut = "Zoom Out"
[viewer.attachments]
addAttachment = "Add attachment"
close = "Close attachments"
empty = "No attachments in this document"
loading = "Loading attachments..."
noDocument = "Open a PDF to view its attachments."
@@ -8044,6 +8046,7 @@ cancelClearAll = "Cancel"
clearAll = "Clear all comments"
clearAllDescription = "This removes comments and replies from the sidebar while keeping any attached annotations in the document."
clearAllTitle = "Clear all comments?"
close = "Close comments"
deleteAnnotationAndComment = "Delete annotation & comment"
deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything."
deleteTitle = "Remove annotation from comments?"
@@ -8054,6 +8057,7 @@ moreActions = "More actions"
nComments_one = "{{count}} comment"
nComments_other = "{{count}} comments"
pageLabel = "Page {{page}}"
placingHint = "Click a page to place… (cancel)"
removeCommentOnly = "Remove comment only"
saveReply = "Save reply"
title = "Comments"
@@ -989,6 +989,35 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
</div>
) : filteredFileStubs.length > 0 ? (
<div className="file-sidebar-file-list">
<button
type="button"
onClick={() => nativeFileInputRef.current?.click()}
data-testid="add-files-row"
style={{
background: "transparent",
border: "none",
cursor: "pointer",
color: "var(--text-muted)",
padding: "4px 6px",
marginBottom: 4,
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 12,
width: "100%",
textAlign: "left",
borderRadius: 4,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--hover-bg)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
<AddIcon sx={{ fontSize: "0.9rem" }} />
{t("fileSidebar.addFiles", "Add files")}
</button>
{filteredFileStubs.map((stub) => {
const workbenchFileId = state.files.ids.find(
(id) => (id as string) === (stub.id as string),
@@ -4,12 +4,14 @@ import {
ScrollArea,
Text,
ActionIcon,
Button,
Loader,
Stack,
TextInput,
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useViewer } from "@app/contexts/ViewerContext";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { PdfAttachmentObject } from "@embedpdf/models";
import AttachmentIcon from "@mui/icons-material/AttachmentRounded";
import DownloadIcon from "@mui/icons-material/DownloadRounded";
@@ -52,7 +54,9 @@ export const AttachmentSidebar = ({
preloadCacheKeys = [],
}: AttachmentSidebarProps) => {
const { t } = useTranslation();
const { attachmentActions, hasAttachmentSupport } = useViewer();
const { attachmentActions, hasAttachmentSupport, toggleAttachmentSidebar } =
useViewer();
const { handleToolSelectForced } = useToolWorkflow();
const [searchTerm, setSearchTerm] = useState("");
const [attachmentSupport, setAttachmentSupport] = useState(() =>
hasAttachmentSupport(),
@@ -139,16 +143,28 @@ export const AttachmentSidebar = ({
const key = documentCacheKey;
const cached = cacheRef.current.get(key);
if (
cached &&
(cached.status === "loading" || cached.status === "success")
) {
// Only short-circuit on a finalised success cache. Skipping when
// cached.status === "loading" caused the sidebar to get stuck: if
// the previous fetch was cancelled (by a parent re-render that
// changed the attachmentActions reference - createViewerActions
// builds a new object every viewer render), the cache still says
// "loading" but no live fetch is in flight. On the re-run we'd
// early-return and never refetch, so the UI would sit on the
// "Loading attachments..." state forever. Same change applied in
// BookmarkSidebar.
if (cached && cached.status === "success") {
return;
}
let cancelled = false;
// Don't write "loading" into the cache - keep the cache for
// terminal states (success/error) only, so a cancelled run can
// never leave a stale "loading" entry behind. The visible
// sidebar state still goes through setActiveEntry below.
const updateEntry = (entry: AttachmentCacheEntry) => {
cacheRef.current.set(key, entry);
if (entry.status === "success" || entry.status === "error") {
cacheRef.current.set(key, entry);
}
if (!cancelled && currentKeyRef.current === key) {
setActiveEntry(entry);
}
@@ -163,10 +179,20 @@ export const AttachmentSidebar = ({
);
const fetchWithRetry = async () => {
const maxAttempts = 10;
// See BookmarkSidebar - matching change. After a file swap the
// attachment bridge briefly unregisters and the action returns
// null until the new document is loaded; without retrying on
// null we'd cache an empty success and miss freshly-added
// attachments.
const maxAttempts = 30;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const result = await attachmentActions.getAttachments();
if (result === null) {
if (attempt === maxAttempts - 1) return [];
await new Promise((resolve) => setTimeout(resolve, 50));
continue;
}
return Array.isArray(result) ? result : [];
} catch (error: any) {
const message =
@@ -239,6 +265,14 @@ export const AttachmentSidebar = ({
attachmentActions.downloadAttachment(attachment);
};
const handleAddAttachment = useCallback(() => {
// Close the attachment sidebar before opening the tool so the user
// doesn't end up looking at two stacked side panels (the sidebar on
// the right + the tool's settings on the left).
toggleAttachmentSidebar();
handleToolSelectForced("addAttachments");
}, [handleToolSelectForced, toggleAttachmentSidebar]);
const filteredAttachments = useMemo(() => {
const attachments = Array.isArray(activeEntry.attachments)
? activeEntry.attachments
@@ -352,6 +386,18 @@ export const AttachmentSidebar = ({
{t("viewer.attachments.title", "Attachments")}
</Text>
</div>
<Box style={{ display: "flex", alignItems: "center", gap: 2 }}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={toggleAttachmentSidebar}
aria-label="Close attachments sidebar"
title={t("viewer.attachments.close", "Close attachments")}
>
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
</ActionIcon>
</Box>
</div>
<Box
@@ -427,20 +473,56 @@ export const AttachmentSidebar = ({
)}
{showEmptyState && (
<div className="sidebar-base__empty-state">
<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>
</div>
<Button
variant="light"
size="xs"
onClick={handleAddAttachment}
leftSection={
<LocalIcon icon="add" width="1rem" height="1rem" />
}
>
{t("viewer.attachments.addAttachment", "Add attachment")}
</Button>
</Stack>
)}
{showAttachmentList && (
<div className="attachment-list">
{renderAttachments(filteredAttachments)}
</div>
<>
<Button
variant="subtle"
size="compact-xs"
fullWidth
onClick={handleAddAttachment}
leftSection={
<LocalIcon icon="add" width="0.9rem" height="0.9rem" />
}
mb="xs"
styles={{
root: {
justifyContent: "flex-start",
paddingInline: 6,
},
}}
>
{t("viewer.attachments.addAttachment", "Add attachment")}
</Button>
<div className="attachment-list">
{renderAttachments(filteredAttachments)}
</div>
</>
)}
{showSearchEmpty && (
@@ -7,10 +7,18 @@ import {
Loader,
Stack,
TextInput,
NumberInput,
Button,
Group,
UnstyledButton,
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useViewer } from "@app/contexts/ViewerContext";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useFileContext } from "@app/contexts/FileContext";
import { isStirlingFile, type FileId } from "@app/types/fileContext";
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
import apiClient from "@app/services/apiClient";
import { PdfBookmarkObject, PdfActionType } from "@embedpdf/models";
import BookmarksIcon from "@mui/icons-material/BookmarksRounded";
import "@app/components/viewer/SidebarBase.css";
@@ -75,9 +83,25 @@ export const BookmarkSidebar = ({
documentCacheKey,
preloadCacheKeys = [],
}: BookmarkSidebarProps) => {
const { bookmarkActions, scrollActions, hasBookmarkSupport } = useViewer();
const {
bookmarkActions,
scrollActions,
hasBookmarkSupport,
activeFileId,
activeFileIndex,
setActiveFileId,
getScrollState,
toggleBookmarkSidebar,
} = useViewer();
const { handleToolSelectForced } = useToolWorkflow();
const { selectors, actions: fileActions } = useFileContext();
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [searchTerm, setSearchTerm] = useState("");
const [isAddingBookmark, setIsAddingBookmark] = useState(false);
const [newBookmarkTitle, setNewBookmarkTitle] = useState("");
const [newBookmarkPage, setNewBookmarkPage] = useState<number>(1);
const [isSavingBookmark, setIsSavingBookmark] = useState(false);
const [addBookmarkError, setAddBookmarkError] = useState<string | null>(null);
const [bookmarkSupport, setBookmarkSupport] = useState(() =>
hasBookmarkSupport(),
);
@@ -164,16 +188,23 @@ export const BookmarkSidebar = ({
const key = documentCacheKey;
const cached = cacheRef.current.get(key);
if (
cached &&
(cached.status === "loading" || cached.status === "success")
) {
// Only short-circuit on a finalised success cache. Skipping when
// cached.status === "loading" causes the sidebar to get stuck if
// the previous fetch was cancelled by a parent re-render (the
// bookmarkActions reference changes every viewer render because
// createViewerActions rebuilds the object). See matching change
// in AttachmentSidebar.
if (cached && cached.status === "success") {
return;
}
let cancelled = false;
// Don't write "loading" into the cache - cache only terminal
// states so a cancelled run can't poison the cache.
const updateEntry = (entry: BookmarkCacheEntry) => {
cacheRef.current.set(key, entry);
if (entry.status === "success" || entry.status === "error") {
cacheRef.current.set(key, entry);
}
if (!cancelled && currentKeyRef.current === key) {
setActiveEntry(entry);
}
@@ -188,10 +219,24 @@ export const BookmarkSidebar = ({
);
const fetchWithRetry = async () => {
const maxAttempts = 10;
// 30 × 50ms = 1.5s window. After consumeFiles swaps the file the
// embedpdf bookmark plugin tears down for the old document and
// re-registers for the new one; until the bridge is back the
// action returns null. Without retrying on null we'd cache an
// empty "success" and the just-added bookmark would never show
// up in the sidebar.
const maxAttempts = 30;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const result = await bookmarkActions.fetchBookmarks();
if (result === null) {
// Bridge not registered yet (document still loading). Wait
// and retry instead of caching this as a successful empty
// list.
if (attempt === maxAttempts - 1) return [];
await new Promise((resolve) => setTimeout(resolve, 50));
continue;
}
return Array.isArray(result) ? result : [];
} catch (error: any) {
const message =
@@ -256,6 +301,143 @@ export const BookmarkSidebar = ({
setFetchNonce((value) => value + 1);
}, [documentCacheKey, bookmarkActions]);
const handleOpenAddBookmark = useCallback(() => {
setAddBookmarkError(null);
setNewBookmarkTitle("");
// Default the new bookmark's target page to whatever page the user is
// currently viewing - matches Acrobat / Foxit behaviour.
const currentPage = getScrollState?.()?.currentPage ?? 1;
setNewBookmarkPage(currentPage);
setIsAddingBookmark(true);
}, [getScrollState]);
const handleCancelAddBookmark = useCallback(() => {
setIsAddingBookmark(false);
setAddBookmarkError(null);
setNewBookmarkTitle("");
}, []);
// Fallback: open the full Edit Table of Contents tool when inline add is
// not viable (e.g. the active file is a preview / unmanaged file we
// cannot consume + replace via FileContext).
const handleFallbackToTool = useCallback(() => {
handleToolSelectForced("editTableOfContents");
}, [handleToolSelectForced]);
const handleSubmitAddBookmark = useCallback(async () => {
const title = newBookmarkTitle.trim();
if (!title) {
setAddBookmarkError("Bookmark title is required");
return;
}
// Resolve the file the viewer is currently displaying. activeFileId
// is only set explicitly (user clicked a thumbnail / a tool ran);
// on a fresh /read upload it stays null and the viewer falls back
// to activeFileIndex - so we mirror that here. Without this, Save
// would silently route to the full editor every time on a fresh
// upload.
const allFiles = selectors.getFiles();
const resolvedFile = activeFileId
? allFiles.find((f) => isStirlingFile(f) && f.fileId === activeFileId)
: (allFiles[activeFileIndex] ?? allFiles[0]);
const resolvedFileId =
resolvedFile && isStirlingFile(resolvedFile)
? (resolvedFile.fileId as FileId)
: null;
if (!resolvedFileId) {
handleFallbackToTool();
return;
}
const fileId = resolvedFileId;
const file = selectors.getFile(fileId);
const parentStub = selectors.getStirlingFileStub(fileId);
if (!file || !parentStub) {
handleFallbackToTool();
return;
}
setIsSavingBookmark(true);
setAddBookmarkError(null);
try {
// Convert existing PDF bookmarks (from embedpdf) to the backend's
// payload shape, then append the new one.
const toPayload = (
b: PdfBookmarkObject,
): {
title: string;
pageNumber: number;
children: any[];
} => ({
title: b.title ?? "",
pageNumber: resolvePageNumber(b) ?? 1,
children: (b.children ?? []).map(toPayload),
});
const existing = (activeEntry.bookmarks ?? []).map(toPayload);
const bookmarkData = [
...existing,
{ title, pageNumber: newBookmarkPage, children: [] },
];
const formData = new FormData();
formData.append("fileInput", file);
formData.append("replaceExisting", "true");
formData.append("bookmarkData", JSON.stringify(bookmarkData));
const response = await apiClient.post(
"/api/v1/general/edit-table-of-contents",
formData,
{ responseType: "blob" },
);
const newFile = new File([response.data as Blob], file.name, {
type: "application/pdf",
});
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(
[newFile],
parentStub,
"editTableOfContents",
);
const outputFileIds = await fileActions.consumeFiles(
[fileId],
stirlingFiles,
stubs,
);
// Point the viewer at the new file. Without this the viewer's
// activeFileId-removed effect nulls activeFileId (old file is
// gone) and the activeFileIndex falls back to 0, which races
// against the embedpdf plugin reloading - the bookmark /
// attachment bridges can end up stuck in a "loading" state.
// useToolOperation does the same thing after consumeFiles.
if (outputFileIds.length === 1) {
setActiveFileId(outputFileIds[0]);
}
// Reset form. The cache is keyed by documentCacheKey (== fileId);
// the new fileId triggers our document-switch effect, which
// resets state and re-fetches once the embedpdf bookmark
// capability has the new document loaded.
setIsAddingBookmark(false);
setNewBookmarkTitle("");
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to save bookmark";
setAddBookmarkError(message);
} finally {
setIsSavingBookmark(false);
}
}, [
newBookmarkTitle,
newBookmarkPage,
activeFileId,
activeFileIndex,
selectors,
fileActions,
setActiveFileId,
activeEntry.bookmarks,
handleFallbackToTool,
]);
const bookmarksWithIds = useMemo(() => {
const assignIds = (
nodes: PdfBookmarkObject[],
@@ -293,34 +475,6 @@ export const BookmarkSidebar = ({
}));
};
const expandAll = useCallback(() => {
const allExpanded: Record<string, boolean> = {};
const expandRecursive = (nodes: BookmarkNode[]) => {
nodes.forEach((node) => {
if (node.children && node.children.length > 0) {
allExpanded[node.id] = true;
expandRecursive(node.children as BookmarkNode[]);
}
});
};
expandRecursive(bookmarksWithIds);
setExpanded(allExpanded);
}, [bookmarksWithIds]);
const collapseAll = useCallback(() => {
const allCollapsed: Record<string, boolean> = {};
const collapseRecursive = (nodes: BookmarkNode[]) => {
nodes.forEach((node) => {
if (node.children && node.children.length > 0) {
allCollapsed[node.id] = false;
collapseRecursive(node.children as BookmarkNode[]);
}
});
};
collapseRecursive(bookmarksWithIds);
setExpanded(allCollapsed);
}, [bookmarksWithIds]);
const handleBookmarkClick = (
bookmark: PdfBookmarkObject,
event: React.MouseEvent,
@@ -499,31 +653,18 @@ export const BookmarkSidebar = ({
Bookmarks
</Text>
</div>
{bookmarkSupport && bookmarksWithIds.length > 0 && (
<>
{Object.values(expanded).some((val) => val === false) ? (
<ActionIcon
variant="subtle"
size="sm"
onClick={expandAll}
aria-label="Expand all bookmarks"
title="Expand all"
>
<LocalIcon icon="unfold-more" width="1.1rem" height="1.1rem" />
</ActionIcon>
) : (
<ActionIcon
variant="subtle"
size="sm"
onClick={collapseAll}
aria-label="Collapse all bookmarks"
title="Collapse all"
>
<LocalIcon icon="unfold-less" width="1.1rem" height="1.1rem" />
</ActionIcon>
)}
</>
)}
<Box style={{ display: "flex", alignItems: "center", gap: 2 }}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={toggleBookmarkSidebar}
aria-label="Close bookmarks sidebar"
title="Close bookmarks"
>
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
</ActionIcon>
</Box>
</div>
<Box
@@ -586,18 +727,119 @@ export const BookmarkSidebar = ({
</Stack>
)}
{showEmptyState && (
<div className="sidebar-base__empty-state">
{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>
</div>
<Button
variant="light"
size="xs"
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(--border-subtle)",
borderRadius: 6,
background: "var(--bg-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="Bookmark title"
aria-label="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="xs"
variant="default"
onClick={handleCancelAddBookmark}
disabled={isSavingBookmark}
>
Cancel
</Button>
<Button
size="xs"
color="blue"
onClick={handleSubmitAddBookmark}
loading={isSavingBookmark}
disabled={!newBookmarkTitle.trim()}
>
Save
</Button>
</Group>
</Stack>
</Box>
)}
{showBookmarkList && (
<div className="bookmark-list">
{renderBookmarks(filteredBookmarks)}
</div>
<>
{!isAddingBookmark && (
<Button
variant="subtle"
size="compact-xs"
fullWidth
onClick={handleOpenAddBookmark}
leftSection={
<LocalIcon icon="add" width="0.9rem" height="0.9rem" />
}
mb="xs"
styles={{
root: {
justifyContent: "flex-start",
paddingInline: 6,
},
}}
>
Add bookmark
</Button>
)}
<div className="bookmark-list">
{renderBookmarks(filteredBookmarks)}
</div>
</>
)}
{showSearchEmpty && (
@@ -609,6 +851,41 @@ export const BookmarkSidebar = ({
)}
</Box>
</ScrollArea>
{bookmarkSupport && documentCacheKey && (
<Box
px="sm"
py="xs"
style={{
borderTop: "1px solid var(--border-subtle)",
backgroundColor: "var(--bg-toolbar)",
flexShrink: 0,
}}
>
<UnstyledButton
type="button"
onClick={handleFallbackToTool}
style={{ width: "100%" }}
>
<Group gap="xs" justify="center" wrap="nowrap">
<LocalIcon
icon="bookmark-add-rounded"
width="0.95rem"
height="0.95rem"
style={{ color: "var(--mantine-color-blue-5)" }}
/>
<Text
size="xs"
c="blue.5"
ta="center"
style={{ textDecoration: "underline" }}
>
Need to reorder or nest? Open the Bookmark Editor
</Text>
</Group>
</UnstyledButton>
</Box>
)}
</Box>
);
};
@@ -311,11 +311,17 @@ export function CommentsSidebar({
clearHighlightCommentRequest,
scrollActions,
getZoomState,
toggleCommentsSidebar,
} = useViewer() ?? {};
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
const { state, provides } = useAnnotation(documentId);
const { handleToolSelectForced } = useToolWorkflow();
const { activateAnnotationToolRef } = useAnnotationContext();
const {
activateAnnotationToolRef,
activeAnnotationToolId,
setActiveAnnotationToolId,
} = useAnnotationContext();
const isPlacingComment = activeAnnotationToolId === TEXT_COMMENT_TOOL_ID;
const [draftContents, setDraftContents] = useState<Record<string, string>>(
{},
);
@@ -639,12 +645,34 @@ export function CommentsSidebar({
);
const handleAddComment = useCallback(() => {
// Keep the sidebar open this time - the button morphs into a
// "Click on a page... cancel" hint so the user can see exactly
// what state the viewer is in.
handleToolSelectForced(ANNOTATE_PANEL_ID);
requestAnimationFrame(() => {
activateAnnotationToolRef.current?.(TEXT_COMMENT_TOOL_ID);
});
}, [handleToolSelectForced, activateAnnotationToolRef]);
const handleCancelPlacingComment = useCallback(() => {
// De-arm the textComment tool. The panel's activateAnnotationTool
// takes the AnnotationToolId "select" to reset to no-tool state.
activateAnnotationToolRef.current?.("select" as never);
setActiveAnnotationToolId(null);
}, [activateAnnotationToolRef, setActiveAnnotationToolId]);
// ESC cancels placement mode while the sidebar is open.
useEffect(() => {
if (!visible || !isPlacingComment) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleCancelPlacingComment();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [visible, isPlacingComment, handleCancelPlacingComment]);
if (!visible) return null;
return (
@@ -716,6 +744,18 @@ export function CommentsSidebar({
</Menu>
</Group>
)}
{toggleCommentsSidebar && (
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={toggleCommentsSidebar}
aria-label="Close comments sidebar"
title={t("viewer.comments.close", "Close comments")}
>
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
</ActionIcon>
)}
</div>
<ScrollArea style={{ flex: 1 }}>
<Stack p="sm" gap="md">
@@ -733,434 +773,517 @@ export function CommentsSidebar({
"Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page.",
)}
</Text>
<Button
variant="light"
size="xs"
onClick={handleAddComment}
leftSection={
<LocalIcon icon="add" width="1rem" height="1rem" />
}
>
{t("viewer.comments.addComment", "Add comment")}
</Button>
{isPlacingComment ? (
<Button
variant="light"
color="orange"
size="xs"
onClick={handleCancelPlacingComment}
leftSection={
<LocalIcon
icon="touch-app-rounded"
width="1rem"
height="1rem"
/>
}
>
{t(
"viewer.comments.placingHint",
"Click a page to place… (cancel)",
)}
</Button>
) : (
<Button
variant="light"
size="xs"
onClick={handleAddComment}
leftSection={
<LocalIcon icon="add" width="1rem" height="1rem" />
}
>
{t("viewer.comments.addComment", "Add comment")}
</Button>
)}
</Stack>
) : (
pageNumbers.map((pageIndex) => {
const entries = byPage[pageIndex] ?? [];
const pageNum = pageIndex + 1;
return (
<Box key={pageIndex} mb="md">
<Text size="sm" fw={700} mb={2}>
{t("viewer.comments.pageLabel", "Page {{page}}", {
page: pageNum,
})}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t("viewer.comments.nComments", "{{count}} comment(s)", {
count: entries.length,
})}
</Text>
<Box
mb="xs"
style={{
borderBottom: "1px solid var(--border-subtle)",
}}
/>
<Stack gap="sm">
{entries.map((entry) => {
const ann = entry.annotation?.object;
const id = ann?.id;
if (!id) return null;
const key = `${pageIndex}_${id}`;
const replyKey = `${pageIndex}_${id}_reply`;
const displayContent = getCommentDisplayContent(entry);
const draft =
draftContents[key] !== undefined
? draftContents[key]
: displayContent;
const replyDraft = replyDrafts[replyKey] ?? "";
const authorName = getAuthorName(ann, displayName);
/** Only treat as "comment posted" when annotation actually has content (user clicked Send), not on every keystroke. */
const hasMainContent =
(displayContent ?? "").trim().length > 0;
const isEditingMain = editingMainKey === key;
<>
{isPlacingComment ? (
<Button
variant="light"
color="orange"
size="compact-xs"
fullWidth
onClick={handleCancelPlacingComment}
leftSection={
<LocalIcon
icon="touch-app-rounded"
width="0.9rem"
height="0.9rem"
/>
}
styles={{
root: {
justifyContent: "flex-start",
paddingInline: 6,
},
}}
>
{t(
"viewer.comments.placingHint",
"Click a page to place… (cancel)",
)}
</Button>
) : (
<Button
variant="subtle"
size="compact-xs"
fullWidth
onClick={handleAddComment}
leftSection={
<LocalIcon icon="add" width="0.9rem" height="0.9rem" />
}
styles={{
root: {
justifyContent: "flex-start",
paddingInline: 6,
},
}}
>
{t("viewer.comments.addComment", "Add comment")}
</Button>
)}
{pageNumbers.map((pageIndex) => {
const entries = byPage[pageIndex] ?? [];
const pageNum = pageIndex + 1;
return (
<Box key={pageIndex} mb="md">
<Text size="sm" fw={700} mb={2}>
{t("viewer.comments.pageLabel", "Page {{page}}", {
page: pageNum,
})}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t("viewer.comments.nComments", "{{count}} comments", {
count: entries.length,
})}
</Text>
<Box
mb="xs"
style={{
borderBottom: "1px solid var(--border-subtle)",
}}
/>
<Stack gap="sm">
{entries.map((entry) => {
const ann = entry.annotation?.object;
const id = ann?.id;
if (!id) return null;
const key = `${pageIndex}_${id}`;
const replyKey = `${pageIndex}_${id}_reply`;
const displayContent = getCommentDisplayContent(entry);
const draft =
draftContents[key] !== undefined
? draftContents[key]
: displayContent;
const replyDraft = replyDrafts[replyKey] ?? "";
const authorName = getAuthorName(ann, displayName);
/** Only treat as "comment posted" when annotation actually has content (user clicked Send), not on every keystroke. */
const hasMainContent =
(displayContent ?? "").trim().length > 0;
const isEditingMain = editingMainKey === key;
const mainTimestamp = formatCommentDate(ann);
const typeLabel = getAnnotationTypeLabel(ann, t);
const mainTimestamp = formatCommentDate(ann);
const typeLabel = getAnnotationTypeLabel(ann, t);
return (
<Box
key={key}
data-comment-card={key}
p="sm"
style={{
border: selectedAnnotationIds.has(id)
? "1px solid var(--mantine-color-blue-3)"
: "1px solid var(--border-subtle)",
borderRadius: 8,
backgroundColor: "var(--bg-raised)",
}}
>
<Group
wrap="nowrap"
gap="xs"
justify="space-between"
align="flex-start"
mb="xs"
return (
<Box
key={key}
data-comment-card={key}
p="sm"
style={{
border: selectedAnnotationIds.has(id)
? "1px solid var(--mantine-color-blue-3)"
: "1px solid var(--border-subtle)",
borderRadius: 8,
backgroundColor: "var(--bg-raised)",
}}
>
<Group
wrap="nowrap"
gap="xs"
style={{ minWidth: 0, flex: 1 }}
justify="space-between"
align="flex-start"
mb="xs"
>
<AnnotationTypeIcon ann={ann} />
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{authorName}
</Text>
<Text size="xs" c="dimmed">
{typeLabel}
{mainTimestamp ? ` · ${mainTimestamp}` : ""}
</Text>
</Box>
</Group>
<Group
gap={2}
wrap="nowrap"
style={{ flexShrink: 0 }}
>
<Tooltip
label={t(
"viewer.comments.locateAnnotation",
"Locate in document",
)}
<Group
wrap="nowrap"
gap="xs"
style={{ minWidth: 0, flex: 1 }}
>
<AnnotationTypeIcon ann={ann} />
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{authorName}
</Text>
<Text size="xs" c="dimmed">
{typeLabel}
{mainTimestamp ? ` · ${mainTimestamp}` : ""}
</Text>
</Box>
</Group>
<Group
gap={2}
wrap="nowrap"
style={{ flexShrink: 0 }}
>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={() =>
handleLocateAnnotation(pageIndex, ann)
}
>
<VisibilityIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
<Menu position="bottom-end" withArrow>
<Menu.Target>
<Tooltip
label={t(
"viewer.comments.moreActions",
"More actions",
)}
>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
>
<MoreHorizIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={
<EditIcon style={{ fontSize: 18 }} />
}
onClick={() => setEditingMainKey(key)}
>
{t("annotation.editText", "Edit")}
</Menu.Item>
<Menu.Item
leftSection={
<DeleteIcon style={{ fontSize: 18 }} />
}
color="red"
onClick={() =>
handleDeleteClick(pageIndex, id, ann)
}
>
{t("annotation.delete", "Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
{!hasMainContent || isEditingMain ? (
<>
<Textarea
placeholder={t(
"viewer.comments.addCommentPlaceholder",
"Add comment...",
)}
minRows={2}
autosize
value={draft ?? ""}
onChange={(e) => {
const v =
(e?.currentTarget ?? e?.target)?.value ??
"";
setDraftContents((prev) => ({
...prev,
[key]: v,
}));
if (isEditingMain) {
handleContentsChange(pageIndex, id, v);
}
}}
styles={{ root: { width: "100%" } }}
mb="xs"
/>
<Group gap={4} wrap="nowrap" justify="flex-end">
<Tooltip
label={t(
"viewer.comments.addComment",
"Add comment",
"viewer.comments.locateAnnotation",
"Locate in document",
)}
>
<ActionIcon
variant="filled"
variant="subtle"
size="sm"
color="blue"
onClick={() => {
handleSendMainComment(
pageIndex,
id,
draft ?? "",
);
setEditingMainKey(null);
}}
disabled={!(draft ?? "").trim()}
color="gray"
onClick={() =>
handleLocateAnnotation(pageIndex, ann)
}
>
<CheckIcon
style={{ fontSize: 18, color: "white" }}
/>
<VisibilityIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
</Group>
</>
) : (
<>
<Text
size="sm"
mb="sm"
style={{ whiteSpace: "pre-wrap" }}
>
{displayContent}
</Text>
{entry.replies?.length ? (
<Stack gap="sm" mb="sm">
{entry.replies.map((r) => {
const rObj = r?.object;
const rId = rObj?.id;
if (!rId) return null;
const rAuthor = getAuthorName(
rObj,
displayName,
);
const rTimestamp = formatCommentDate(rObj);
const replyEditKey = `${pageIndex}_${id}_${rId}`;
const isEditingReply =
editingReplyKey === replyEditKey;
const canEditReply =
isReplyAuthoredByCurrentUser(
rObj,
displayName,
);
const replyBody =
replyEditDrafts[replyEditKey] !==
undefined
? replyEditDrafts[replyEditKey]
: (rObj?.contents ?? "");
return (
<Box
key={rId}
pl="xs"
style={{
borderLeft:
"2px solid var(--mantine-color-blue-3)",
}}
<Menu position="bottom-end" withArrow>
<Menu.Target>
<Tooltip
label={t(
"viewer.comments.moreActions",
"More actions",
)}
>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
>
<Box style={{ minWidth: 0 }}>
<Group
wrap="nowrap"
justify="space-between"
align="flex-start"
gap={4}
mb={2}
>
<Text size="sm" fw={600}>
{rAuthor}
</Text>
<Group
wrap="nowrap"
gap="xs"
align="center"
>
{canEditReply &&
!isEditingReply ? (
<UnstyledButton
type="button"
onClick={() => {
setEditingReplyKey(
replyEditKey,
);
setReplyEditDrafts(() => ({
[replyEditKey]: String(
rObj?.contents ?? "",
),
}));
}}
>
<Text size="xs" c="blue">
{t(
"annotation.editText",
"Edit",
)}
</Text>
</UnstyledButton>
) : null}
{rTimestamp ? (
<Text size="xs" c="dimmed">
{rTimestamp}
</Text>
) : null}
</Group>
</Group>
{isEditingReply ? (
<>
<Textarea
minRows={2}
autosize
value={replyBody}
onChange={(e) => {
const v =
(
e?.currentTarget ??
e?.target
)?.value ?? "";
setReplyEditDrafts((p) => ({
...p,
[replyEditKey]: v,
}));
}}
styles={{
root: { width: "100%" },
}}
mb="xs"
/>
<Group
gap={4}
wrap="nowrap"
justify="flex-end"
>
<Tooltip
label={t(
"viewer.comments.saveReply",
"Save reply",
)}
>
<ActionIcon
variant="filled"
size="sm"
color="blue"
onClick={() =>
handleSaveReplyEdit(
replyEditKey,
pageIndex,
rId,
replyBody,
)
}
disabled={!replyBody.trim()}
>
<CheckIcon
style={{
fontSize: 18,
color: "white",
}}
/>
</ActionIcon>
</Tooltip>
</Group>
</>
) : (
<Text
size="sm"
style={{ whiteSpace: "pre-wrap" }}
>
{rObj?.contents ?? ""}
</Text>
)}
</Box>
</Box>
);
})}
</Stack>
) : null}
<MoreHorizIcon
style={{ fontSize: 20 }}
/>
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={
<EditIcon style={{ fontSize: 18 }} />
}
onClick={() => setEditingMainKey(key)}
>
{t("annotation.editText", "Edit")}
</Menu.Item>
<Menu.Item
leftSection={
<DeleteIcon style={{ fontSize: 18 }} />
}
color="red"
onClick={() =>
handleDeleteClick(pageIndex, id, ann)
}
>
{t("annotation.delete", "Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
<Group gap="xs" wrap="nowrap" align="flex-end">
<TextInput
{!hasMainContent || isEditingMain ? (
<>
<Textarea
placeholder={t(
"viewer.comments.addReplyPlaceholder",
"Add reply...",
"viewer.comments.addCommentPlaceholder",
"Add comment...",
)}
size="xs"
value={replyDraft}
minRows={2}
autosize
value={draft ?? ""}
onChange={(e) => {
const v =
(e?.currentTarget ?? e?.target)?.value ??
"";
setReplyDrafts((p) => ({
...p,
[replyKey]: v,
setDraftContents((prev) => ({
...prev,
[key]: v,
}));
}}
style={{ flex: 1, minWidth: 0 }}
styles={{
input: {
borderColor:
"var(--mantine-color-blue-3)",
},
}}
/>
<Tooltip
label={t(
"viewer.comments.addComment",
"Add comment",
)}
>
<ActionIcon
variant="filled"
size="md"
color="blue"
style={{
backgroundColor:
"var(--mantine-color-blue-6)",
}}
onClick={() =>
handleSendReply(pageIndex, id, ann?.rect)
if (isEditingMain) {
handleContentsChange(pageIndex, id, v);
}
disabled={!replyDraft.trim()}
}}
styles={{ root: { width: "100%" } }}
mb="xs"
/>
<Group gap={4} wrap="nowrap" justify="flex-end">
<Tooltip
label={t(
"viewer.comments.addComment",
"Add comment",
)}
>
<CheckIcon
style={{ fontSize: 20, color: "white" }}
/>
</ActionIcon>
</Tooltip>
</Group>
</>
)}
</Box>
);
})}
</Stack>
</Box>
);
})
<ActionIcon
variant="filled"
size="sm"
color="blue"
onClick={() => {
handleSendMainComment(
pageIndex,
id,
draft ?? "",
);
setEditingMainKey(null);
}}
disabled={!(draft ?? "").trim()}
>
<CheckIcon
style={{ fontSize: 18, color: "white" }}
/>
</ActionIcon>
</Tooltip>
</Group>
</>
) : (
<>
<Text
size="sm"
mb="sm"
style={{ whiteSpace: "pre-wrap" }}
>
{displayContent}
</Text>
{entry.replies?.length ? (
<Stack gap="sm" mb="sm">
{entry.replies.map((r) => {
const rObj = r?.object;
const rId = rObj?.id;
if (!rId) return null;
const rAuthor = getAuthorName(
rObj,
displayName,
);
const rTimestamp =
formatCommentDate(rObj);
const replyEditKey = `${pageIndex}_${id}_${rId}`;
const isEditingReply =
editingReplyKey === replyEditKey;
const canEditReply =
isReplyAuthoredByCurrentUser(
rObj,
displayName,
);
const replyBody =
replyEditDrafts[replyEditKey] !==
undefined
? replyEditDrafts[replyEditKey]
: (rObj?.contents ?? "");
return (
<Box
key={rId}
pl="xs"
style={{
borderLeft:
"2px solid var(--mantine-color-blue-3)",
}}
>
<Box style={{ minWidth: 0 }}>
<Group
wrap="nowrap"
justify="space-between"
align="flex-start"
gap={4}
mb={2}
>
<Text size="sm" fw={600}>
{rAuthor}
</Text>
<Group
wrap="nowrap"
gap="xs"
align="center"
>
{canEditReply &&
!isEditingReply ? (
<UnstyledButton
type="button"
onClick={() => {
setEditingReplyKey(
replyEditKey,
);
setReplyEditDrafts(
() => ({
[replyEditKey]:
String(
rObj?.contents ??
"",
),
}),
);
}}
>
<Text size="xs" c="blue">
{t(
"annotation.editText",
"Edit",
)}
</Text>
</UnstyledButton>
) : null}
{rTimestamp ? (
<Text size="xs" c="dimmed">
{rTimestamp}
</Text>
) : null}
</Group>
</Group>
{isEditingReply ? (
<>
<Textarea
minRows={2}
autosize
value={replyBody}
onChange={(e) => {
const v =
(
e?.currentTarget ??
e?.target
)?.value ?? "";
setReplyEditDrafts((p) => ({
...p,
[replyEditKey]: v,
}));
}}
styles={{
root: { width: "100%" },
}}
mb="xs"
/>
<Group
gap={4}
wrap="nowrap"
justify="flex-end"
>
<Tooltip
label={t(
"viewer.comments.saveReply",
"Save reply",
)}
>
<ActionIcon
variant="filled"
size="sm"
color="blue"
onClick={() =>
handleSaveReplyEdit(
replyEditKey,
pageIndex,
rId,
replyBody,
)
}
disabled={
!replyBody.trim()
}
>
<CheckIcon
style={{
fontSize: 18,
color: "white",
}}
/>
</ActionIcon>
</Tooltip>
</Group>
</>
) : (
<Text
size="sm"
style={{
whiteSpace: "pre-wrap",
}}
>
{rObj?.contents ?? ""}
</Text>
)}
</Box>
</Box>
);
})}
</Stack>
) : null}
<Group gap="xs" wrap="nowrap" align="flex-end">
<TextInput
placeholder={t(
"viewer.comments.addReplyPlaceholder",
"Add reply...",
)}
size="xs"
value={replyDraft}
onChange={(e) => {
const v =
(e?.currentTarget ?? e?.target)
?.value ?? "";
setReplyDrafts((p) => ({
...p,
[replyKey]: v,
}));
}}
style={{ flex: 1, minWidth: 0 }}
styles={{
input: {
borderColor:
"var(--mantine-color-blue-3)",
},
}}
/>
<Tooltip
label={t(
"viewer.comments.addComment",
"Add comment",
)}
>
<ActionIcon
variant="filled"
size="md"
color="blue"
style={{
backgroundColor:
"var(--mantine-color-blue-6)",
}}
onClick={() =>
handleSendReply(
pageIndex,
id,
ann?.rect,
)
}
disabled={!replyDraft.trim()}
>
<CheckIcon
style={{ fontSize: 20, color: "white" }}
/>
</ActionIcon>
</Tooltip>
</Group>
</>
)}
</Box>
);
})}
</Stack>
</Box>
);
})}
</>
)}
</Stack>
</ScrollArea>
@@ -12,6 +12,8 @@ import {
import LayersIcon from "@mui/icons-material/Layers";
import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useViewer } from "@app/contexts/ViewerContext";
import "@app/components/viewer/SidebarBase.css";
import "@app/components/viewer/LayerSidebar.css";
import {
@@ -49,6 +51,7 @@ export function LayerSidebar({
onApplyLayers,
onLayersDetected,
}: LayerSidebarProps) {
const { toggleLayerSidebar } = useViewer();
const [layers, setLayers] = useState<LayerInfo[]>([]);
const [visibility, setVisibility] = useState<Record<string, boolean>>({});
const [status, setStatus] = useState<LoadStatus>("idle");
@@ -337,30 +340,42 @@ export function LayerSidebar({
{isApplying && <Loader size="xs" type="dots" />}
</div>
{status === "ready" && leafIds.length > 0 && (
<div className="layer-sidebar__header-actions">
<ActionIcon
variant="subtle"
size="sm"
onClick={showAll}
disabled={allVisible || isApplying}
aria-label="Show all layers"
title="Show all"
>
<VisibilityIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={hideAll}
disabled={allHidden || isApplying}
aria-label="Hide all layers"
title="Hide all"
>
<VisibilityOffIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</div>
)}
<div className="layer-sidebar__header-actions">
{status === "ready" && leafIds.length > 0 && (
<>
<ActionIcon
variant="subtle"
size="sm"
onClick={showAll}
disabled={allVisible || isApplying}
aria-label="Show all layers"
title="Show all"
>
<VisibilityIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={hideAll}
disabled={allHidden || isApplying}
aria-label="Hide all layers"
title="Hide all"
>
<VisibilityOffIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</>
)}
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={toggleLayerSidebar}
aria-label="Close layers sidebar"
title="Close layers"
>
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
</ActionIcon>
</div>
</div>
{/* Content */}
@@ -1,7 +1,10 @@
import { useState, useEffect, useRef } from "react";
import { Box, ScrollArea } from "@mantine/core";
import { Box, ScrollArea, Text, ActionIcon } from "@mantine/core";
import { useViewer } from "@app/contexts/ViewerContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import LocalIcon from "@app/components/shared/LocalIcon";
import ViewListIcon from "@mui/icons-material/ViewList";
import "@app/components/viewer/SidebarBase.css";
interface ThumbnailSidebarProps {
visible: boolean;
@@ -11,7 +14,7 @@ interface ThumbnailSidebarProps {
export function ThumbnailSidebar({
visible,
onToggle: _onToggle,
onToggle,
activeFileId,
}: ThumbnailSidebarProps) {
const { getScrollState, scrollActions, getThumbnailAPI } = useViewer();
@@ -153,20 +156,42 @@ export function ThumbnailSidebar({
{/* Thumbnail Sidebar */}
{visible && (
<Box
className="sidebar-base"
style={{
position: "fixed",
right: 0,
top: 0,
bottom: 0,
width: "15rem",
backgroundColor: "var(--bg-surface)",
borderLeft: "1px solid var(--border-subtle)",
zIndex: 998,
display: "flex",
flexDirection: "column",
boxShadow: "-2px 0 8px rgba(0, 0, 0, 0.1)",
}}
>
<div className="sidebar-base__header">
<div className="sidebar-base__header-title">
<span className="sidebar-base__header-icon">
<ViewListIcon fontSize="small" />
</span>
<Text
fw={600}
size="sm"
tt="uppercase"
lts={0.5}
style={{ flex: 1 }}
>
Pages
</Text>
</div>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={onToggle}
aria-label="Close thumbnails sidebar"
title="Close thumbnails"
>
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
</ActionIcon>
</div>
{/* Thumbnails Container */}
<ScrollArea style={{ flex: 1 }}>
<Box p="sm">
@@ -1,4 +1,11 @@
import React, { createContext, useContext, ReactNode, useRef } from "react";
import React, {
createContext,
useContext,
ReactNode,
useRef,
useState,
useCallback,
} from "react";
import type {
AnnotationAPI,
AnnotationToolId,
@@ -10,6 +17,16 @@ interface AnnotationContextValue {
activateAnnotationToolRef: React.RefObject<
((toolId: AnnotationToolId) => void) | null
>;
/**
* Observable copy of the annotation panel's currently armed tool.
* Annotate.tsx mirrors its local activeTool state here so the
* CommentsSidebar (and other components outside the panel) can react
* to "textComment is armed" without coupling to the panel's internals.
* Null when no annotation tool is armed (panel not mounted or set to
* "select").
*/
activeAnnotationToolId: AnnotationToolId | null;
setActiveAnnotationToolId: (id: AnnotationToolId | null) => void;
}
const AnnotationContext = createContext<AnnotationContextValue | undefined>(
@@ -23,10 +40,20 @@ export const AnnotationProvider: React.FC<{ children: ReactNode }> = ({
const activateAnnotationToolRef = useRef<
((toolId: AnnotationToolId) => void) | null
>(null);
const [activeAnnotationToolId, setActiveAnnotationToolIdState] =
useState<AnnotationToolId | null>(null);
const setActiveAnnotationToolId = useCallback(
(id: AnnotationToolId | null) => {
setActiveAnnotationToolIdState(id);
},
[],
);
const value: AnnotationContextValue = {
annotationApiRef,
activateAnnotationToolRef,
activeAnnotationToolId,
setActiveAnnotationToolId,
};
return (
@@ -0,0 +1,218 @@
import { test, expect } from "@app/tests/helpers/test-base";
import { loginAndSetup } from "@app/tests/helpers/login";
import { runToolAndWaitForReview } from "@app/tests/helpers/ui-helpers";
import * as path from "path";
import * as fs from "fs";
import {
PDFDocument,
PDFName,
PDFDict,
PDFString,
PDFHexString,
} from "@cantoo/pdf-lib";
/**
* End-to-end validation of the new "Add attachment" and "Add bookmark"
* buttons on the viewer's attachment / bookmark sidebars.
*
* Each test:
* 1. Logs in and uploads a sample PDF via the Read tool's viewer.
* 2. Opens the relevant sidebar.
* 3. Confirms the empty-state Add button is visible.
* 4. Clicks it - URL must switch to the corresponding tool page.
* 5. Completes the tool's flow (pick a file to attach / type a
* bookmark title).
* 6. Runs the tool and intercepts the backend response.
* 7. Loads the produced PDF with pdf-lib and verifies it actually
* contains the new attachment / new bookmark.
*
* Requires a real Spring Boot backend on :8080 - registered under the
* `live` Playwright project. The `live-setup` project bootstraps the
* admin user before this runs.
*/
function fixture(filename: string): string {
const candidates = [
path.resolve(
process.cwd(),
"src",
"core",
"tests",
"test-fixtures",
filename,
),
path.resolve(
process.cwd(),
"frontend",
"src",
"core",
"tests",
"test-fixtures",
filename,
),
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
throw new Error(
`Test fixture not found: ${filename} (tried: ${candidates.join(", ")})`,
);
}
async function openSamplePdfInViewer(page: import("@playwright/test").Page) {
await page.goto("/read");
await page.waitForLoadState("domcontentloaded");
await page
.locator('[data-testid="file-input"]')
.first()
.setInputFiles(fixture("sample.pdf"));
// Page indicator confirms the embedded viewer has the document loaded.
await expect(page.getByText(/\/\s*1/).first()).toBeVisible({
timeout: 30_000,
});
}
test.describe("Viewer sidebar add buttons - real PDF round-trip", () => {
test.describe.configure({ timeout: 180_000 });
test.beforeEach(async ({ page }) => {
await loginAndSetup(page);
});
test("Add attachment from viewer sidebar embeds the chosen file in the produced PDF", async ({
page,
}) => {
await openSamplePdfInViewer(page);
await page
.getByRole("button", { name: /Toggle Attachments/i })
.first()
.click();
const addBtn = page.getByRole("button", { name: /^Add attachment$/i });
await expect(addBtn).toBeVisible({ timeout: 15_000 });
await addBtn.click();
await expect(page).toHaveURL(/\/add-attachments$/, { timeout: 10_000 });
// Hidden picker the AddAttachments tool exposes. Attach a small known
// file - reuse the sample fixture as the attachment payload so we can
// assert on its filename below.
const attachmentName = "sample.pdf";
await page
.locator("#attachments-input")
.setInputFiles(fixture(attachmentName));
// Capture the backend response so we can inspect the produced PDF.
const responsePromise = page.waitForResponse(
(r) =>
/\/api\/v1\/(general|misc)\/add-attachments$/.test(r.url()) &&
r.status() === 200,
{ timeout: 90_000 },
);
await runToolAndWaitForReview(page);
const response = await responsePromise;
const pdfBytes = await response.body();
// Sanity: response is a PDF (starts with %PDF-)
expect(pdfBytes.slice(0, 5).toString()).toBe("%PDF-");
// Verify the produced PDF actually contains the attachment.
const doc = await PDFDocument.load(pdfBytes, {
ignoreEncryption: true,
throwOnInvalidObject: false,
});
const attachments = doc.getAttachments();
expect(attachments.length).toBeGreaterThan(0);
expect(attachments.map((a) => a.name)).toContain(attachmentName);
});
test("Add bookmark from viewer sidebar adds the bookmark to the produced PDF outline", async ({
page,
}) => {
await openSamplePdfInViewer(page);
const viewerUrl = page.url();
await page
.getByRole("button", { name: /Toggle Bookmarks/i })
.first()
.click();
const addBtn = page.getByRole("button", { name: /^Add bookmark$/i });
await expect(addBtn).toBeVisible({ timeout: 15_000 });
await addBtn.click();
// Stays in the viewer - URL doesn't change to /edit-table-of-contents.
expect(page.url()).toBe(viewerUrl);
// Inline form appears inside the sidebar with title + page inputs.
const form = page.locator('[data-testid="bookmark-add-form"]');
await expect(form).toBeVisible({ timeout: 10_000 });
const BOOKMARK_TITLE = `Playwright test bookmark ${Date.now()}`;
await form
.getByRole("textbox", { name: /Bookmark title/i })
.fill(BOOKMARK_TITLE);
// Capture the backend POST the inline Save kicks off.
const responsePromise = page.waitForResponse(
(r) =>
/\/api\/v1\/general\/edit-table-of-contents$/.test(r.url()) &&
r.status() === 200,
{ timeout: 90_000 },
);
await form.getByRole("button", { name: /^Save$/i }).click();
const response = await responsePromise;
const pdfBytes = await response.body();
// Form should close on success and the user should still be in the
// viewer (no tool navigation).
await expect(form).not.toBeVisible({ timeout: 10_000 });
expect(page.url()).toBe(viewerUrl);
expect(pdfBytes.slice(0, 5).toString()).toBe("%PDF-");
// Verify the produced PDF has an /Outlines entry in the catalog and
// the bookmark title we set is present in the document.
const doc = await PDFDocument.load(pdfBytes, {
ignoreEncryption: true,
throwOnInvalidObject: false,
});
const outlinesRef = doc.catalog.get(PDFName.of("Outlines"));
expect(outlinesRef, "PDF catalog must have /Outlines entry").toBeDefined();
// Walk the outline tree and collect titles.
const outlinesDict = doc.context.lookup(outlinesRef, PDFDict);
const collectTitles = (
dictRef: ReturnType<PDFDict["get"]> | undefined,
acc: string[],
): string[] => {
if (!dictRef) return acc;
const node = doc.context.lookupMaybe(dictRef, PDFDict);
if (!node) return acc;
const title = node.get(PDFName.of("Title"));
if (title instanceof PDFString || title instanceof PDFHexString) {
try {
acc.push(title.decodeText());
} catch {
// Title couldn't decode - fall back to asString
acc.push(title.asString());
}
}
collectTitles(node.get(PDFName.of("First")), acc);
collectTitles(node.get(PDFName.of("Next")), acc);
return acc;
};
const titles = collectTitles(outlinesDict.get(PDFName.of("First")), []);
expect(
titles,
`expected outline titles to include "${BOOKMARK_TITLE}", got ${JSON.stringify(titles)}`,
).toContain(BOOKMARK_TITLE);
});
});
@@ -0,0 +1,198 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
/**
* Verifies the new "Add attachment" and "Add bookmark" buttons on the
* viewer's attachment + bookmark sidebars.
*
* After the UX refactor:
* - Attachment sidebar's Add button still navigates to the
* AddAttachments tool, BUT also closes the attachment sidebar so
* the user doesn't see two stacked side panels.
* - Bookmark sidebar's Add button opens an inline form (title + page,
* defaulting to the current page) inside the sidebar - the user
* never leaves the viewer. Submitting POSTs to the backend (not
* covered here - see the live spec for that).
*
* Backend-free spec.
*/
const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
async function openViewerWithSample(page: import("@playwright/test").Page) {
await page.goto("/read");
await page.waitForLoadState("domcontentloaded");
await page
.locator('[data-testid="file-input"]')
.first()
.setInputFiles(SAMPLE_PDF);
await expect(page.getByText(/\/\s*1/).first()).toBeVisible({
timeout: 30_000,
});
}
test.describe("Viewer sidebar: Add attachment / Add bookmark buttons", () => {
test("Attachment sidebar Add button navigates to tool and closes the sidebar", async ({
page,
}) => {
await openViewerWithSample(page);
const attachmentsToggle = page
.getByRole("button", { name: /Toggle Attachments/i })
.first();
await attachmentsToggle.click();
// Sidebar header shows up
const sidebarTitle = page.getByText(/^Attachments$/i).first();
await expect(sidebarTitle).toBeVisible({ timeout: 10_000 });
const addBtn = page.getByRole("button", { name: /^Add attachment$/i });
await expect(addBtn).toBeVisible({ timeout: 15_000 });
await addBtn.click();
// URL syncs to /add-attachments
await expect(page).toHaveURL(/\/add-attachments$/, { timeout: 10_000 });
// Sidebar should have auto-closed (no stacked panels)
await expect(sidebarTitle).not.toBeVisible({ timeout: 5_000 });
});
test("Bookmark sidebar Add button opens an inline form (no navigation away from viewer)", async ({
page,
}) => {
await openViewerWithSample(page);
const initialUrl = page.url();
await page
.getByRole("button", { name: /Toggle Bookmarks/i })
.first()
.click();
await expect(page.getByText(/^Bookmarks$/i).first()).toBeVisible({
timeout: 10_000,
});
const addBtn = page.getByRole("button", { name: /^Add bookmark$/i });
await expect(addBtn).toBeVisible({ timeout: 15_000 });
await addBtn.click();
// Inline form appears (title + page inputs + Save/Cancel) - the
// form is identifiable by its data-testid so we don't depend on
// matching label text fragility.
const form = page.locator('[data-testid="bookmark-add-form"]');
await expect(form).toBeVisible({ timeout: 10_000 });
await expect(
form.getByRole("textbox", { name: /Bookmark title/i }),
).toBeVisible();
await expect(form.getByRole("button", { name: /^Save$/i })).toBeVisible();
await expect(form.getByRole("button", { name: /^Cancel$/i })).toBeVisible();
// Crucially, the URL did NOT change to the tool route - user stays
// in the viewer.
expect(page.url()).toBe(initialUrl);
// Cancel closes the form (no backend hit)
await form.getByRole("button", { name: /^Cancel$/i }).click();
await expect(form).not.toBeVisible({ timeout: 5_000 });
});
test("Bookmark Save POSTs to backend, closes the form, and stays in viewer", async ({
page,
}) => {
// Stub the edit-table-of-contents endpoint with a tiny valid PDF
// blob so the Save flow can complete without the live backend.
// The minimal PDF below is just enough that PDFDocument loaders
// accept it as application/pdf - we don't need real bookmarks in
// the response for this assertion. We only check that:
// (1) the click actually issues the POST (regression for the
// silent-fallback bug where activeFileId was null on a fresh
// upload and Save quietly routed to the full editor); and
// (2) the form closes and the viewer URL doesn't change.
const MINIMAL_PDF =
"%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Count 1/Kids[3 0 R]>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 10 10]>>endobj\nxref\n0 4\n0000000000 65535 f\n0000000010 00000 n\n0000000053 00000 n\n0000000098 00000 n\ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n149\n%%EOF\n";
let postSeen = false;
await page.route(
"**/api/v1/general/edit-table-of-contents",
async (route) => {
postSeen = true;
await route.fulfill({
status: 200,
contentType: "application/pdf",
body: Buffer.from(MINIMAL_PDF, "binary"),
});
},
);
await openViewerWithSample(page);
const viewerUrl = page.url();
await page
.getByRole("button", { name: /Toggle Bookmarks/i })
.first()
.click();
await page.getByRole("button", { name: /^Add bookmark$/i }).click();
const form = page.locator('[data-testid="bookmark-add-form"]');
await expect(form).toBeVisible({ timeout: 10_000 });
await form
.getByRole("textbox", { name: /Bookmark title/i })
.fill("Stub bookmark");
await form.getByRole("button", { name: /^Save$/i }).click();
// The POST must actually have fired. The earlier silent-fallback
// bug would have routed to /edit-table-of-contents without ever
// calling the API.
await expect.poll(() => postSeen, { timeout: 15_000 }).toBe(true);
// Form closes on success, viewer URL unchanged (no tool nav).
await expect(form).not.toBeVisible({ timeout: 10_000 });
expect(page.url()).toBe(viewerUrl);
});
test("Each sidebar header has a close (X) button that dismisses it", async ({
page,
}) => {
await openViewerWithSample(page);
// Visibility of the close button itself is the stable signal:
// it's rendered only when the sidebar is mounted.
//
// Mantine 8 ActionIcon has a known pointer-event sequence quirk
// where Playwright's synthesized click() can leave the button in
// mantine-active without firing the React onClick handler. We
// sidestep it by dispatching mousedown + mouseup + click explicitly,
// which mirrors a real user's pointer interaction in browser.
const closeViaButton = async (name: RegExp) => {
const btn = page.getByRole("button", { name });
await expect(btn).toBeVisible({ timeout: 10_000 });
await btn.dispatchEvent("mousedown");
await btn.dispatchEvent("mouseup");
await btn.dispatchEvent("click");
await expect(btn).not.toBeVisible({ timeout: 5_000 });
};
// Bookmark sidebar
await page
.getByRole("button", { name: /Toggle Bookmarks/i })
.first()
.click();
await closeViaButton(/Close bookmarks sidebar/i);
// Attachment sidebar
await page
.getByRole("button", { name: /Toggle Attachments/i })
.first()
.click();
await closeViaButton(/Close attachments sidebar/i);
// Thumbnail sidebar
await page
.getByRole("button", { name: /Toggle Sidebar/i })
.first()
.click();
await closeViaButton(/Close thumbnails sidebar/i);
});
});
+17 -2
View File
@@ -89,7 +89,8 @@ const Annotate = (_props: BaseToolProps) => {
placementPreviewSize,
setPlacementPreviewSize,
} = useSignature();
const { activateAnnotationToolRef } = useAnnotationContext();
const { activateAnnotationToolRef, setActiveAnnotationToolId } =
useAnnotationContext();
const viewerContext = useContext(ViewerContext);
const viewerContextRef = useRef(viewerContext);
useEffect(() => {
@@ -141,7 +142,21 @@ const Annotate = (_props: BaseToolProps) => {
useEffect(() => {
activeToolRef.current = activeTool;
}, [activeTool]);
// Mirror the panel's armed tool to AnnotationContext so callers
// outside the panel (CommentsSidebar's Add Comment hint) can react
// without coupling to this component's internals. "select" means
// nothing is armed - publish null so consumers can branch cheaply.
setActiveAnnotationToolId(activeTool === "select" ? null : activeTool);
}, [activeTool, setActiveAnnotationToolId]);
// Make sure we clear the published tool when the annotation panel
// unmounts (user navigated away from /annotate). Otherwise the
// sidebar would think textComment is still armed.
useEffect(() => {
return () => {
setActiveAnnotationToolId(null);
};
}, [setActiveAnnotationToolId]);
// CSS to PDF size conversion accounting for zoom
const cssToPdfSize = useCallback(