mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f501805111 | ||
|
|
f64e955598 | ||
|
|
112b95df6a | ||
|
|
bcfca006d9 | ||
|
|
0e9f6334b8 | ||
|
|
ebd2d5752b |
@@ -64,17 +64,20 @@ export function ColorControl({
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={label}>
|
||||
<ActionIcon
|
||||
aria-label={label}
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
<Tooltip label={label} withArrow>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-floating-btn"
|
||||
onClick={() => setOpened(!opened)}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
>
|
||||
<ColorSwatch color={localColor} size={18} />
|
||||
</ActionIcon>
|
||||
<ColorSwatch
|
||||
color={localColor}
|
||||
size={18}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import OpacityIcon from "@mui/icons-material/Opacity";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
|
||||
interface OpacityControlProps {
|
||||
value: number; // 0-100
|
||||
@@ -21,17 +20,16 @@ export function OpacityControl({
|
||||
return (
|
||||
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("annotation.opacity", "Opacity")}>
|
||||
<ActionIcon
|
||||
aria-label={t("annotation.opacity", "Opacity")}
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
<Tooltip label={t("annotation.opacity", "Opacity")} withArrow>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-floating-btn"
|
||||
onClick={() => setOpened(!opened)}
|
||||
disabled={disabled}
|
||||
aria-label={t("annotation.opacity", "Opacity")}
|
||||
>
|
||||
<OpacityIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
|
||||
@@ -195,17 +195,16 @@ export function PropertiesPopover({
|
||||
return (
|
||||
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("annotation.properties", "Properties")}>
|
||||
<ActionIcon
|
||||
aria-label={t("annotation.properties", "Properties")}
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
<Tooltip label={t("annotation.properties", "Properties")} withArrow>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-floating-btn"
|
||||
onClick={() => setOpened(!opened)}
|
||||
disabled={disabled}
|
||||
aria-label={t("annotation.properties", "Properties")}
|
||||
>
|
||||
<TuneIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import LineWeightIcon from "@mui/icons-material/LineWeight";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
|
||||
interface WidthControlProps {
|
||||
value: number;
|
||||
@@ -25,17 +24,16 @@ export function WidthControl({
|
||||
return (
|
||||
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("annotation.width", "Width")}>
|
||||
<ActionIcon
|
||||
aria-label={t("annotation.width", "Width")}
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
<Tooltip label={t("annotation.width", "Width")} withArrow>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-floating-btn"
|
||||
onClick={() => setOpened(!opened)}
|
||||
disabled={disabled}
|
||||
aria-label={t("annotation.width", "Width")}
|
||||
>
|
||||
<LineWeightIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
|
||||
@@ -43,7 +43,8 @@ export default function ManualRedactionControls({
|
||||
const { signatureApiRef } = useSignature();
|
||||
|
||||
// Check if user is navigating away (modal shown) — don't fight the save/leave process
|
||||
const { showNavigationWarning } = useNavigationGuard();
|
||||
const { showNavigationWarning, hasUnsavedChanges: navHasUnsavedChanges } =
|
||||
useNavigationGuard();
|
||||
|
||||
// Track the previous file index to detect file switches
|
||||
const prevFileIndexRef = useRef<number>(activeFileIndex);
|
||||
@@ -121,8 +122,9 @@ export default function ManualRedactionControls({
|
||||
}
|
||||
}, [applyChanges]);
|
||||
|
||||
// Check if there are unsaved changes to save (pending redactions OR applied redactions)
|
||||
const hasUnsavedChanges = pendingCount > 0 || redactionsApplied;
|
||||
// Check if there are unsaved changes to save (pending redactions OR applied redactions OR unsaved changes)
|
||||
const hasUnsavedChanges =
|
||||
pendingCount > 0 || redactionsApplied || navHasUnsavedChanges;
|
||||
|
||||
const isApiReady = isBridgeReady;
|
||||
|
||||
|
||||
@@ -8,22 +8,21 @@ import AddCommentIcon from "@mui/icons-material/AddCommentOutlined";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNewRounded";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import type { FirstLinkTarget } from "@app/components/viewer/useAnnotationMenuHandlers";
|
||||
import "@app/components/viewer/TextSelectionMenu.css";
|
||||
|
||||
export function DeleteButton({ onDelete }: { onDelete: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip label={t("annotation.delete", "Delete")}>
|
||||
<ActionIcon
|
||||
aria-label={t("annotation.delete", "Delete")}
|
||||
variant="secondary"
|
||||
accent="danger"
|
||||
size="md"
|
||||
<Tooltip label={t("annotation.delete", "Delete")} withArrow>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-floating-btn embedpdf-floating-btn-danger"
|
||||
onClick={onDelete}
|
||||
aria-label={t("annotation.delete", "Delete")}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -31,16 +30,15 @@ export function DeleteButton({ onDelete }: { onDelete: () => void }) {
|
||||
export function EditTextButton({ onEdit }: { onEdit: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip label={t("annotation.editText", "Edit Text")}>
|
||||
<ActionIcon
|
||||
aria-label={t("annotation.editText", "Edit Text")}
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
<Tooltip label={t("annotation.editText", "Edit Text")} withArrow>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-floating-btn"
|
||||
onClick={onEdit}
|
||||
aria-label={t("annotation.editText", "Edit Text")}
|
||||
>
|
||||
<EditIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -61,16 +59,15 @@ export function AttachCommentButton({
|
||||
? t("viewer.comments.viewComment", "View comment")
|
||||
: t("viewer.comments.addComment", "Add comment");
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<ActionIcon
|
||||
aria-label={label}
|
||||
variant={isInSidebar ? "primary" : "secondary"}
|
||||
accent={isInSidebar ? undefined : "neutral"}
|
||||
size="md"
|
||||
<Tooltip label={label} withArrow>
|
||||
<button
|
||||
type="button"
|
||||
className={`embedpdf-floating-btn ${isInSidebar ? "embedpdf-floating-btn-active" : ""}`}
|
||||
onClick={isInSidebar ? onView : onAdd}
|
||||
aria-label={label}
|
||||
>
|
||||
<AddCommentIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -86,16 +83,15 @@ export function CommentButton({ hasContent, onClick }: CommentButtonProps) {
|
||||
? t("viewer.comments.viewComment", "View comment")
|
||||
: t("viewer.comments.addComment", "Add comment");
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<ActionIcon
|
||||
aria-label={label}
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
<Tooltip label={label} withArrow>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-floating-btn"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
>
|
||||
<CommentIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -117,33 +113,36 @@ export function LinkButton({
|
||||
|
||||
if (firstLinkTarget) {
|
||||
return (
|
||||
<Tooltip label={t("viewer.comments.goToLink", "Go to link")}>
|
||||
<ActionIcon
|
||||
aria-label={t("viewer.comments.goToLink", "Go to link")}
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
<Tooltip label={t("viewer.comments.goToLink", "Go to link")} withArrow>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-floating-btn"
|
||||
onClick={onGoToLink}
|
||||
aria-label={t("viewer.comments.goToLink", "Go to link")}
|
||||
>
|
||||
<OpenInNewIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover opened={open} onClose={() => setOpen(false)} position="top">
|
||||
<Popover
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
position="top"
|
||||
withArrow
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("viewer.comments.addLink", "Add link")}>
|
||||
<ActionIcon
|
||||
aria-label={t("viewer.comments.addLink", "Add link")}
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
<Tooltip label={t("viewer.comments.addLink", "Add link")} withArrow>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-floating-btn"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label={t("viewer.comments.addLink", "Add link")}
|
||||
>
|
||||
<LocalIcon icon="link" width="1.25rem" height="1.25rem" />
|
||||
</ActionIcon>
|
||||
<LocalIcon icon="link" width="1.15rem" height="1.15rem" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Group } from "@mantine/core";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from "react";
|
||||
import { useAnnotation } from "@embedpdf/plugin-annotation/react";
|
||||
@@ -12,6 +11,7 @@ import { useActiveDocumentId } from "@app/components/viewer/useActiveDocumentId"
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useAnnotationMenuHandlers } from "@app/components/viewer/useAnnotationMenuHandlers";
|
||||
import { AnnotationTypeButtons } from "@app/components/viewer/AnnotationTypeButtons";
|
||||
import "@app/components/viewer/TextSelectionMenu.css";
|
||||
|
||||
/**
|
||||
* Props interface matching EmbedPDF's annotation selection menu pattern
|
||||
@@ -125,7 +125,12 @@ function AnnotationSelectionMenuInner({
|
||||
if (!selected) return;
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("[data-annotation-selection-menu]")) return;
|
||||
if (
|
||||
target.closest(
|
||||
"[data-annotation-selection-menu],[data-text-selection-menu],[data-redaction-selection-menu]",
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (target.closest("[data-no-interaction]")) return;
|
||||
if (target.closest(".mantine-Popover-dropdown")) return;
|
||||
provides?.deselectAnnotation?.();
|
||||
@@ -185,6 +190,7 @@ function AnnotationSelectionMenuInner({
|
||||
const menuContent = menuPosition ? (
|
||||
<div
|
||||
data-annotation-selection-menu
|
||||
className="embedpdf-floating-menu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: `${menuPosition.top}px`,
|
||||
@@ -192,26 +198,17 @@ function AnnotationSelectionMenuInner({
|
||||
transform: "translateX(-50%)",
|
||||
pointerEvents: "auto",
|
||||
zIndex: 10000,
|
||||
backgroundColor: "var(--mantine-color-body)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 12px",
|
||||
boxShadow: "0 2px 12px rgba(0, 0, 0, 0.25)",
|
||||
border: "1px solid var(--mantine-color-default-border)",
|
||||
fontSize: "14px",
|
||||
minWidth: `${handlers.menuWidth}px`,
|
||||
transition: "min-width 0.2s ease",
|
||||
width: "max-content",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" justify="center">
|
||||
<AnnotationTypeButtons
|
||||
{...handlers}
|
||||
isInSidebar={isInSidebar}
|
||||
annotation={annotation}
|
||||
documentId={documentId}
|
||||
pageIndex={pageIndex}
|
||||
annotationId={handlers.annotationId}
|
||||
/>
|
||||
</Group>
|
||||
<AnnotationTypeButtons
|
||||
{...handlers}
|
||||
isInSidebar={isInSidebar}
|
||||
annotation={annotation}
|
||||
documentId={documentId}
|
||||
pageIndex={pageIndex}
|
||||
annotationId={handlers.annotationId}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useCommentAuthor } from "@app/contexts/CommentAuthorContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useAnnotation as useAnnotationContext } from "@app/contexts/AnnotationContext";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { compareEntriesByVisualOrder } from "@app/components/viewer/commentsSidebarOrder";
|
||||
import { SidebarBase } from "@app/components/viewer/SidebarBase";
|
||||
@@ -309,6 +310,7 @@ export function CommentsSidebar({
|
||||
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const { state, provides } = useAnnotation(documentId);
|
||||
const { handleToolSelectForced } = useToolWorkflow();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const {
|
||||
activateAnnotationToolRef,
|
||||
activeAnnotationToolId,
|
||||
@@ -544,14 +546,16 @@ export function CommentsSidebar({
|
||||
// post-reload linked annotations, so clearing it removes the annotation
|
||||
// from the sidebar (contents is not visually rendered on ink/shape/markup types).
|
||||
provides.updateAnnotation(pageIndex, id, getRemoveCommentPatch(ann));
|
||||
navActions?.setHasUnsavedChanges(true);
|
||||
setDeleteModal(null);
|
||||
}, [deleteModal, provides]);
|
||||
}, [deleteModal, provides, navActions]);
|
||||
|
||||
const handleDeleteAnnotation = useCallback(() => {
|
||||
if (!deleteModal) return;
|
||||
provides?.deleteAnnotation?.(deleteModal.pageIndex, deleteModal.id);
|
||||
navActions?.setHasUnsavedChanges(true);
|
||||
setDeleteModal(null);
|
||||
}, [deleteModal, provides]);
|
||||
}, [deleteModal, provides, navActions]);
|
||||
|
||||
const handleClearAllComments = useCallback(() => {
|
||||
const annotationsToDelete: Array<{ pageIndex: number; id: string }> = [];
|
||||
@@ -611,6 +615,7 @@ export function CommentsSidebar({
|
||||
}
|
||||
}
|
||||
|
||||
navActions?.setHasUnsavedChanges(true);
|
||||
setDraftContents({});
|
||||
setReplyDrafts({});
|
||||
setReplyEditDrafts({});
|
||||
@@ -618,7 +623,7 @@ export function CommentsSidebar({
|
||||
setEditingReplyKey(null);
|
||||
setDeleteModal(null);
|
||||
setClearAllModalOpen(false);
|
||||
}, [byPage, provides]);
|
||||
}, [byPage, provides, navActions]);
|
||||
|
||||
const handleSendMainComment = useCallback(
|
||||
(pageIndex: number, annotationId: string, value: string) => {
|
||||
@@ -628,12 +633,13 @@ export function CommentsSidebar({
|
||||
contents: trimmed,
|
||||
author: displayName,
|
||||
});
|
||||
navActions?.setHasUnsavedChanges(true);
|
||||
setDraftContents((prev) => ({
|
||||
...prev,
|
||||
[pageIndex + "_" + annotationId]: trimmed,
|
||||
}));
|
||||
},
|
||||
[provides, displayName],
|
||||
[provides, displayName, navActions],
|
||||
);
|
||||
|
||||
const handleSendReply = useCallback(
|
||||
@@ -656,9 +662,10 @@ export function CommentsSidebar({
|
||||
author: displayName,
|
||||
};
|
||||
provides.createAnnotation(pageIndex, reply);
|
||||
navActions?.setHasUnsavedChanges(true);
|
||||
setReplyDrafts((prev) => ({ ...prev, [key]: "" }));
|
||||
},
|
||||
[provides, replyDrafts, displayName],
|
||||
[provides, replyDrafts, displayName, navActions],
|
||||
);
|
||||
|
||||
const handleSaveReplyEdit = useCallback(
|
||||
@@ -669,6 +676,7 @@ export function CommentsSidebar({
|
||||
contents: trimmed,
|
||||
author: displayName,
|
||||
});
|
||||
navActions?.setHasUnsavedChanges(true);
|
||||
setReplyEditDrafts((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[editKey];
|
||||
@@ -676,7 +684,7 @@ export function CommentsSidebar({
|
||||
});
|
||||
setEditingReplyKey(null);
|
||||
},
|
||||
[provides, displayName],
|
||||
[provides, displayName, navActions],
|
||||
);
|
||||
|
||||
const handleAddComment = useCallback(() => {
|
||||
@@ -1021,15 +1029,28 @@ export function CommentsSidebar({
|
||||
</Group>
|
||||
|
||||
{!hasMainContent || isEditingMain ? (
|
||||
<>
|
||||
<Textarea
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
<TextInput
|
||||
placeholder={t(
|
||||
"viewer.comments.addCommentPlaceholder",
|
||||
"Add comment...",
|
||||
)}
|
||||
minRows={2}
|
||||
autosize
|
||||
size="sm"
|
||||
value={draft ?? ""}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
(draft ?? "").trim()
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleSendMainComment(
|
||||
pageIndex,
|
||||
id,
|
||||
draft ?? "",
|
||||
);
|
||||
setEditingMainKey(null);
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const v =
|
||||
(e?.currentTarget ?? e?.target)?.value ??
|
||||
@@ -1042,38 +1063,49 @@ export function CommentsSidebar({
|
||||
handleContentsChange(pageIndex, id, v);
|
||||
}
|
||||
}}
|
||||
styles={{ root: { width: "100%" } }}
|
||||
mb="xs"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
styles={{
|
||||
input: {
|
||||
borderColor:
|
||||
"var(--mantine-color-blue-3)",
|
||||
height: "36px",
|
||||
minHeight: "36px",
|
||||
},
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<Group gap={4} wrap="nowrap" justify="flex-end">
|
||||
<Tooltip
|
||||
label={t(
|
||||
<Tooltip
|
||||
label={t(
|
||||
"viewer.comments.addComment",
|
||||
"Add comment",
|
||||
)}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="primary"
|
||||
size="md"
|
||||
aria-label={t(
|
||||
"viewer.comments.addComment",
|
||||
"Add comment",
|
||||
)}
|
||||
onClick={() => {
|
||||
handleSendMainComment(
|
||||
pageIndex,
|
||||
id,
|
||||
draft ?? "",
|
||||
);
|
||||
setEditingMainKey(null);
|
||||
}}
|
||||
disabled={!(draft ?? "").trim()}
|
||||
style={{
|
||||
height: "36px",
|
||||
width: "36px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="primary"
|
||||
size="sm"
|
||||
aria-label={t(
|
||||
"viewer.comments.addComment",
|
||||
"Add comment",
|
||||
)}
|
||||
onClick={() => {
|
||||
handleSendMainComment(
|
||||
pageIndex,
|
||||
id,
|
||||
draft ?? "",
|
||||
);
|
||||
setEditingMainKey(null);
|
||||
}}
|
||||
disabled={!(draft ?? "").trim()}
|
||||
>
|
||||
<CheckIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</>
|
||||
<CheckIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
@@ -1251,14 +1283,27 @@ export function CommentsSidebar({
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Group gap="xs" wrap="nowrap" align="flex-end">
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
<TextInput
|
||||
placeholder={t(
|
||||
"viewer.comments.addReplyPlaceholder",
|
||||
"Add reply...",
|
||||
)}
|
||||
size="xs"
|
||||
size="sm"
|
||||
value={replyDraft}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
replyDraft.trim()
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleSendReply(
|
||||
pageIndex,
|
||||
id,
|
||||
ann?.rect,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const v =
|
||||
(e?.currentTarget ?? e?.target)
|
||||
@@ -1273,6 +1318,8 @@ export function CommentsSidebar({
|
||||
input: {
|
||||
borderColor:
|
||||
"var(--mantine-color-blue-3)",
|
||||
height: "36px",
|
||||
minHeight: "36px",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
@@ -1297,8 +1344,13 @@ export function CommentsSidebar({
|
||||
)
|
||||
}
|
||||
disabled={!replyDraft.trim()}
|
||||
style={{
|
||||
height: "36px",
|
||||
width: "36px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<CheckIcon style={{ fontSize: 20 }} />
|
||||
<CheckIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
@@ -162,7 +162,12 @@ const EmbedPdfViewerContent = ({
|
||||
const applyChangesInFlightRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
// Get redaction context
|
||||
const { redactionsApplied, setRedactionsApplied } = useRedaction();
|
||||
const {
|
||||
redactionsApplied,
|
||||
setRedactionsApplied,
|
||||
deactivateRedact,
|
||||
setRedactionMode,
|
||||
} = useRedaction();
|
||||
|
||||
// Ref for redaction pending tracker API
|
||||
const redactionTrackerRef = useRef<RedactionPendingTrackerAPI>(null);
|
||||
@@ -186,6 +191,13 @@ const EmbedPdfViewerContent = ({
|
||||
|
||||
const { selectedTool } = useNavigationState();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTool !== "redact") {
|
||||
setRedactionMode(false);
|
||||
deactivateRedact();
|
||||
}
|
||||
}, [selectedTool, setRedactionMode, deactivateRedact]);
|
||||
|
||||
// Form fill context
|
||||
const { fetchFields: fetchFormFields, setProviderMode } = useFormFill();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useImperativeHandle, forwardRef, useEffect, useRef } from "react";
|
||||
import { useHistoryCapability } from "@embedpdf/plugin-history/react";
|
||||
import { useAnnotationCapability } from "@embedpdf/plugin-annotation/react";
|
||||
import { useSignature } from "@app/contexts/SignatureContext";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { uuidV4, PdfAnnotationSubtype } from "@embedpdf/models";
|
||||
import type { HistoryAPI } from "@app/components/viewer/viewerTypes";
|
||||
import {
|
||||
@@ -19,14 +20,23 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(
|
||||
const { provides: historyApi } = useHistoryCapability();
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const { getImageData, storeImageData } = useSignature();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const documentReady = useDocumentReady();
|
||||
const restoringIds = useRef<Set<string>>(new Set());
|
||||
|
||||
// Monitor annotation events to detect when annotations are restored
|
||||
// Monitor annotation events to detect when annotations are created/modified/restored
|
||||
useEffect(() => {
|
||||
if (!annotationApi || !documentReady) return;
|
||||
|
||||
const handleAnnotationEvent = (event: any) => {
|
||||
if (
|
||||
event?.type === "create" ||
|
||||
event?.type === "update" ||
|
||||
event?.type === "delete"
|
||||
) {
|
||||
navActions?.setHasUnsavedChanges(true);
|
||||
}
|
||||
|
||||
const annotation = event.annotation;
|
||||
|
||||
// Store image data for all STAMP annotations immediately when created or modified
|
||||
|
||||
@@ -1130,7 +1130,10 @@ export function LocalEmbedPDF({
|
||||
pageIndex={pageIndex}
|
||||
background="var(--pdf-selection-bg)"
|
||||
selectionMenu={(props) => (
|
||||
<TextSelectionMenu {...props} />
|
||||
<TextSelectionMenu
|
||||
{...props}
|
||||
documentId={documentId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useImperativeHandle } from "react";
|
||||
import { useRedaction as useEmbedPdfRedaction } from "@embedpdf/plugin-redaction/react";
|
||||
import { PdfAnnotationSubtype } from "@embedpdf/models";
|
||||
import { useRedaction } from "@app/contexts/RedactionContext";
|
||||
import { useRedaction, useRedactionMode } from "@app/contexts/RedactionContext";
|
||||
import { useNavigationState } from "@app/contexts/NavigationContext";
|
||||
import { useActiveDocumentId } from "@app/components/viewer/useActiveDocumentId";
|
||||
import { useAnnotationCapability } from "@embedpdf/plugin-annotation/react";
|
||||
import { useDocumentReady } from "@app/components/viewer/hooks/useDocumentReady";
|
||||
@@ -26,6 +27,8 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
const { state, provides: redactionProvides } =
|
||||
useEmbedPdfRedaction(documentId);
|
||||
const { provides: annotationProvides } = useAnnotationCapability();
|
||||
const { isRedactionModeActive } = useRedactionMode();
|
||||
const { selectedTool } = useNavigationState();
|
||||
const {
|
||||
redactionApiRef,
|
||||
setPendingCount,
|
||||
@@ -35,13 +38,25 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
manualRedactColor,
|
||||
} = useRedaction();
|
||||
|
||||
// Mark bridge as ready on mount, not ready on unmount
|
||||
// Ensure EmbedPDF exits redaction mode whenever we are not in redact tool mode
|
||||
useEffect(() => {
|
||||
if (selectedTool !== "redact" && !isRedactionModeActive) {
|
||||
if (redactionProvides?.isRedactActive?.()) {
|
||||
redactionProvides.endRedact();
|
||||
}
|
||||
}
|
||||
}, [selectedTool, isRedactionModeActive, redactionProvides]);
|
||||
|
||||
// Mark bridge as ready on mount, cleanup on unmount
|
||||
useEffect(() => {
|
||||
setBridgeReady(true);
|
||||
return () => {
|
||||
setBridgeReady(false);
|
||||
if (redactionProvides?.isRedactActive?.()) {
|
||||
redactionProvides.endRedact();
|
||||
}
|
||||
};
|
||||
}, [setBridgeReady]);
|
||||
}, [setBridgeReady, redactionProvides]);
|
||||
|
||||
// Sync EmbedPDF state to our context
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,9 +3,7 @@ import {
|
||||
RedactionSelectionMenuProps,
|
||||
} from "@embedpdf/plugin-redaction/react";
|
||||
import { PdfAnnotationSubtype } from "@embedpdf/models";
|
||||
import { Tooltip, Group } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
@@ -13,6 +11,7 @@ import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import { useRedaction } from "@app/contexts/RedactionContext";
|
||||
import { useActiveDocumentId } from "@app/components/viewer/useActiveDocumentId";
|
||||
import "@app/components/viewer/TextSelectionMenu.css";
|
||||
|
||||
export type { RedactionSelectionMenuProps };
|
||||
|
||||
@@ -123,55 +122,50 @@ function RedactionSelectionMenuInner({
|
||||
|
||||
const menuContent = menuPosition ? (
|
||||
<div
|
||||
data-redaction-selection-menu
|
||||
className="embedpdf-floating-menu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: `${menuPosition.top}px`,
|
||||
left: `${menuPosition.left}px`,
|
||||
transform: "translateX(-50%)",
|
||||
pointerEvents: "auto",
|
||||
zIndex: 10000, // Very high z-index to appear above everything
|
||||
backgroundColor: "var(--mantine-color-body)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 12px",
|
||||
boxShadow: "0 2px 12px rgba(0, 0, 0, 0.25)",
|
||||
border: "1px solid var(--mantine-color-default-border)",
|
||||
// Fixed size to prevent browser zoom affecting layout
|
||||
fontSize: "14px",
|
||||
minWidth: "180px",
|
||||
zIndex: 10000,
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" justify="center">
|
||||
<Tooltip label={t("viewer.redaction.removeMark", "Remove this mark")}>
|
||||
<ActionIcon
|
||||
aria-label={t("viewer.redaction.removeMark", "Remove this mark")}
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
label={t(
|
||||
"redact.manual.applyWarning",
|
||||
"⚠️ Permanent application, cannot be undone and the data underneath will be deleted",
|
||||
)}
|
||||
withArrow
|
||||
position="top"
|
||||
<Tooltip
|
||||
label={t("viewer.redaction.removeMark", "Remove this mark")}
|
||||
withArrow
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-floating-btn embedpdf-floating-btn-danger"
|
||||
onClick={handleRemove}
|
||||
aria-label={t("viewer.redaction.removeMark", "Remove this mark")}
|
||||
>
|
||||
<Button
|
||||
accent="danger"
|
||||
size="sm"
|
||||
onClick={handleApply}
|
||||
leftSection={<CheckCircleIcon style={{ fontSize: 16 }} />}
|
||||
style={{ flexShrink: 0, whiteSpace: "nowrap" }}
|
||||
>
|
||||
Apply (permanent)
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<DeleteIcon style={{ fontSize: 18 }} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<div className="embedpdf-floating-divider" />
|
||||
|
||||
<Tooltip
|
||||
label={t(
|
||||
"redact.manual.applyWarning",
|
||||
"⚠️ Permanent application, cannot be undone and the data underneath will be deleted",
|
||||
)}
|
||||
withArrow
|
||||
position="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-floating-badge-btn"
|
||||
onClick={handleApply}
|
||||
>
|
||||
<CheckCircleIcon style={{ fontSize: 16 }} />
|
||||
<span>{t("redact.manual.apply", "Apply")}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
.embedpdf-floating-menu,
|
||||
.embedpdf-text-selection-menu {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 32px);
|
||||
background: var(--c-floating-menu-bg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 10px;
|
||||
padding: 6px 12px;
|
||||
box-shadow:
|
||||
0 6px 24px rgba(0, 0, 0, 0.45),
|
||||
0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
color: var(--c-floating-menu-text);
|
||||
user-select: none;
|
||||
backdrop-filter: blur(8px);
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.embedpdf-floating-btn,
|
||||
.embedpdf-text-selection-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--c-floating-menu-text);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
transform 0.1s ease,
|
||||
color 0.15s ease;
|
||||
outline: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.embedpdf-floating-btn:hover,
|
||||
.embedpdf-text-selection-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
color: var(--c-text-on-primary);
|
||||
}
|
||||
|
||||
.embedpdf-floating-btn:active,
|
||||
.embedpdf-text-selection-btn:active {
|
||||
transform: scale(0.92);
|
||||
background-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.embedpdf-floating-btn:focus-visible,
|
||||
.embedpdf-text-selection-btn:focus-visible {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--c-primary) 60%, transparent);
|
||||
}
|
||||
|
||||
.embedpdf-floating-btn-danger {
|
||||
color: var(--c-floating-danger);
|
||||
}
|
||||
|
||||
.embedpdf-floating-btn-danger:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--c-floating-danger) 18%,
|
||||
transparent
|
||||
);
|
||||
color: var(--c-floating-danger-hover);
|
||||
}
|
||||
|
||||
.embedpdf-floating-btn-active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
color: var(--c-text-on-primary);
|
||||
}
|
||||
|
||||
.embedpdf-floating-badge-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--c-floating-danger) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--c-floating-danger) 15%, transparent);
|
||||
color: var(--c-floating-danger-hover);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
transform 0.1s ease,
|
||||
border-color 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.embedpdf-floating-badge-btn:hover {
|
||||
background: color-mix(in srgb, var(--c-floating-danger) 25%, transparent);
|
||||
border-color: color-mix(in srgb, var(--c-floating-danger) 60%, transparent);
|
||||
color: var(--c-text-on-primary);
|
||||
}
|
||||
|
||||
.embedpdf-floating-badge-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.embedpdf-floating-divider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
margin: 0 2px;
|
||||
}
|
||||
@@ -15,6 +15,10 @@ function baseProps(
|
||||
};
|
||||
}
|
||||
|
||||
// TextSelectionMenu reads the active document from ActiveDocumentContext, which
|
||||
// defaults to `null` outside of a live EmbedPDF document-manager session (not something the
|
||||
// shared preview can stub). With no active document it short-circuits and renders nothing,
|
||||
// so this story only exercises that no-active-document mount path without throwing.
|
||||
const meta = {
|
||||
title: "Viewer/TextSelectionMenu",
|
||||
component: TextSelectionMenu,
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { TextSelectionMenu } from "@app/components/viewer/TextSelectionMenu";
|
||||
import { PdfAnnotationSubtype } from "@embedpdf/models";
|
||||
|
||||
// Mock i18next
|
||||
const mockT = vi.fn(
|
||||
(key: string, fallback?: string) => fallback || `mock-${key}`,
|
||||
);
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: mockT }),
|
||||
}));
|
||||
|
||||
// Mock EmbedPDF hooks
|
||||
const mockCopyToClipboard = vi.fn();
|
||||
const mockGetFormattedSelection = vi.fn();
|
||||
const mockGetSelectedText = vi.fn();
|
||||
const mockClearSelection = vi.fn();
|
||||
|
||||
vi.mock("@embedpdf/plugin-selection/react", () => ({
|
||||
useSelectionCapability: () => ({
|
||||
provides: {
|
||||
copyToClipboard: mockCopyToClipboard,
|
||||
getFormattedSelection: mockGetFormattedSelection,
|
||||
getSelectedText: mockGetSelectedText,
|
||||
clear: mockClearSelection,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockCreateAnnotation = vi.fn();
|
||||
vi.mock("@embedpdf/plugin-annotation/react", () => ({
|
||||
useAnnotation: () => ({
|
||||
state: {},
|
||||
provides: {
|
||||
createAnnotation: mockCreateAnnotation,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockQueueCurrentSelectionAsPending = vi.fn();
|
||||
vi.mock("@embedpdf/plugin-redaction/react", () => ({
|
||||
useRedaction: () => ({
|
||||
state: {},
|
||||
provides: {
|
||||
queueCurrentSelectionAsPending: mockQueueCurrentSelectionAsPending,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@app/components/viewer/useActiveDocumentId", () => ({
|
||||
useActiveDocumentId: () => "doc-1",
|
||||
}));
|
||||
|
||||
const mockHandleToolSelectForced = vi.fn();
|
||||
const mockSetSidebarsVisible = vi.fn();
|
||||
const mockSetLeftPanelView = vi.fn();
|
||||
vi.mock("@app/contexts/ToolWorkflowContext", () => ({
|
||||
useToolWorkflow: () => ({
|
||||
handleToolSelectForced: mockHandleToolSelectForced,
|
||||
setSidebarsVisible: mockSetSidebarsVisible,
|
||||
setLeftPanelView: mockSetLeftPanelView,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSetRedactionMode = vi.fn();
|
||||
const mockActivateRedact = vi.fn();
|
||||
const mockSetRedactionConfig = vi.fn();
|
||||
vi.mock("@app/contexts/RedactionContext", () => ({
|
||||
useRedaction: () => ({
|
||||
setRedactionMode: mockSetRedactionMode,
|
||||
activateRedact: mockActivateRedact,
|
||||
setRedactionConfig: mockSetRedactionConfig,
|
||||
redactionApiRef: { current: null },
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSetToolAndWorkbench = vi.fn();
|
||||
const mockSetHasUnsavedChanges = vi.fn();
|
||||
vi.mock("@app/contexts/NavigationContext", () => ({
|
||||
useNavigationActions: () => ({
|
||||
actions: {
|
||||
setToolAndWorkbench: mockSetToolAndWorkbench,
|
||||
setHasUnsavedChanges: mockSetHasUnsavedChanges,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockAlert = vi.fn();
|
||||
vi.mock("@app/components/toast", () => ({
|
||||
alert: (options: any) => mockAlert(options),
|
||||
}));
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
);
|
||||
|
||||
describe("TextSelectionMenu", () => {
|
||||
const defaultProps = {
|
||||
rect: { origin: { x: 100, y: 100 }, size: { width: 80, height: 20 } },
|
||||
menuWrapperProps: {
|
||||
style: {},
|
||||
ref: vi.fn(),
|
||||
},
|
||||
selected: true,
|
||||
placement: {
|
||||
pageIndex: 0,
|
||||
rect: { origin: { x: 100, y: 100 }, size: { width: 80, height: 20 } },
|
||||
spaceAbove: 100,
|
||||
spaceBelow: 200,
|
||||
suggestTop: true,
|
||||
isVisible: true,
|
||||
},
|
||||
context: {
|
||||
type: "selection" as const,
|
||||
pageIndex: 0,
|
||||
},
|
||||
documentId: "doc-1",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetFormattedSelection.mockReturnValue([
|
||||
{
|
||||
pageIndex: 0,
|
||||
rect: { origin: { x: 100, y: 100 }, size: { width: 80, height: 20 } },
|
||||
segmentRects: [
|
||||
{ origin: { x: 100, y: 100 }, size: { width: 80, height: 20 } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("renders all 7 action buttons when selected", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<TextSelectionMenu {...defaultProps} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Highlight" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Strikeout" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Underline" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Squiggly" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add link" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Redact" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render portal content when selected is false", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<TextSelectionMenu {...defaultProps} selected={false} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Copy" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls copyToClipboard, clears selection, and triggers toast popup when Copy button is clicked", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<TextSelectionMenu {...defaultProps} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const copyBtn = screen.getByRole("button", { name: "Copy" });
|
||||
fireEvent.click(copyBtn);
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith("doc-1");
|
||||
expect(mockClearSelection).toHaveBeenCalledWith("doc-1");
|
||||
expect(mockAlert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
alertType: "neutral",
|
||||
title: "Copied to clipboard",
|
||||
durationMs: 2000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("creates HIGHLIGHT annotation, clears selection and opens Annotate UI when Highlight button is clicked", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<TextSelectionMenu {...defaultProps} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const highlightBtn = screen.getByRole("button", { name: "Highlight" });
|
||||
fireEvent.click(highlightBtn);
|
||||
|
||||
expect(mockCreateAnnotation).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
type: PdfAnnotationSubtype.HIGHLIGHT,
|
||||
color: "#FFCD45",
|
||||
pageIndex: 0,
|
||||
}),
|
||||
);
|
||||
expect(mockClearSelection).toHaveBeenCalledWith("doc-1");
|
||||
expect(mockHandleToolSelectForced).toHaveBeenCalledWith("annotate");
|
||||
expect(mockSetSidebarsVisible).toHaveBeenCalledWith(true);
|
||||
expect(mockSetLeftPanelView).toHaveBeenCalledWith("toolContent");
|
||||
});
|
||||
|
||||
test("creates STRIKEOUT annotation, clears selection and opens Annotate UI when Strikeout button is clicked", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<TextSelectionMenu {...defaultProps} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const strikeoutBtn = screen.getByRole("button", { name: "Strikeout" });
|
||||
fireEvent.click(strikeoutBtn);
|
||||
|
||||
expect(mockCreateAnnotation).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
type: PdfAnnotationSubtype.STRIKEOUT,
|
||||
color: "#E44234",
|
||||
pageIndex: 0,
|
||||
}),
|
||||
);
|
||||
expect(mockClearSelection).toHaveBeenCalledWith("doc-1");
|
||||
expect(mockHandleToolSelectForced).toHaveBeenCalledWith("annotate");
|
||||
});
|
||||
|
||||
test("creates UNDERLINE annotation, clears selection and opens Annotate UI when Underline button is clicked", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<TextSelectionMenu {...defaultProps} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const underlineBtn = screen.getByRole("button", { name: "Underline" });
|
||||
fireEvent.click(underlineBtn);
|
||||
|
||||
expect(mockCreateAnnotation).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
type: PdfAnnotationSubtype.UNDERLINE,
|
||||
color: "#E44234",
|
||||
pageIndex: 0,
|
||||
}),
|
||||
);
|
||||
expect(mockClearSelection).toHaveBeenCalledWith("doc-1");
|
||||
expect(mockHandleToolSelectForced).toHaveBeenCalledWith("annotate");
|
||||
});
|
||||
|
||||
test("creates SQUIGGLY annotation, clears selection and opens Annotate UI when Squiggly button is clicked", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<TextSelectionMenu {...defaultProps} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const squigglyBtn = screen.getByRole("button", { name: "Squiggly" });
|
||||
fireEvent.click(squigglyBtn);
|
||||
|
||||
expect(mockCreateAnnotation).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
type: PdfAnnotationSubtype.SQUIGGLY,
|
||||
color: "#E44234",
|
||||
pageIndex: 0,
|
||||
}),
|
||||
);
|
||||
expect(mockClearSelection).toHaveBeenCalledWith("doc-1");
|
||||
expect(mockHandleToolSelectForced).toHaveBeenCalledWith("annotate");
|
||||
});
|
||||
|
||||
test("opens link popover and creates LINK annotation upon submit", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<TextSelectionMenu {...defaultProps} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const linkBtn = screen.getByRole("button", { name: "Add link" });
|
||||
fireEvent.click(linkBtn);
|
||||
|
||||
const input = screen.getByPlaceholderText("https://...");
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, { target: { value: "https://example.com" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(mockCreateAnnotation).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
type: PdfAnnotationSubtype.LINK,
|
||||
target: {
|
||||
type: "action",
|
||||
action: { type: 3, uri: "https://example.com" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(mockClearSelection).toHaveBeenCalledWith("doc-1");
|
||||
expect(mockHandleToolSelectForced).toHaveBeenCalledWith("annotate");
|
||||
});
|
||||
|
||||
test("creates REDACT annotation, marks unsaved changes, and opens Redact UI when Redact button is clicked", () => {
|
||||
mockGetFormattedSelection.mockReturnValue([
|
||||
{
|
||||
pageIndex: 0,
|
||||
rect: { origin: { x: 10, y: 10 }, size: { width: 100, height: 20 } },
|
||||
segmentRects: [],
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<TextSelectionMenu {...defaultProps} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const redactBtn = screen.getByRole("button", { name: "Redact" });
|
||||
fireEvent.click(redactBtn);
|
||||
|
||||
expect(mockCreateAnnotation).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
type: PdfAnnotationSubtype.REDACT,
|
||||
}),
|
||||
);
|
||||
expect(mockClearSelection).toHaveBeenCalledWith("doc-1");
|
||||
expect(mockSetHasUnsavedChanges).toHaveBeenCalledWith(true);
|
||||
expect(mockSetRedactionConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: "manual" }),
|
||||
);
|
||||
expect(mockSetToolAndWorkbench).toHaveBeenCalledWith("redact", "viewer");
|
||||
expect(mockSetSidebarsVisible).toHaveBeenCalledWith(true);
|
||||
expect(mockSetLeftPanelView).toHaveBeenCalledWith("toolContent");
|
||||
expect(mockSetRedactionMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,252 @@
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip, Popover, TextInput, Stack } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import type { SelectionSelectionMenuProps } from "@embedpdf/plugin-selection/react";
|
||||
import { useSelectionCapability } from "@embedpdf/plugin-selection/react";
|
||||
import { useAnnotation } from "@embedpdf/plugin-annotation/react";
|
||||
import { RedactionMode } from "@embedpdf/plugin-redaction";
|
||||
import {
|
||||
PdfAnnotationSubtype,
|
||||
PdfActionType,
|
||||
PdfBlendMode,
|
||||
uuidV4,
|
||||
} from "@embedpdf/models";
|
||||
import { useActiveDocumentId } from "@app/components/viewer/useActiveDocumentId";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useRedaction } from "@app/contexts/RedactionContext";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import {
|
||||
defaultParameters,
|
||||
RedactParameters,
|
||||
} from "@app/hooks/tools/redact/useRedactParameters";
|
||||
import { alert } from "@app/components/toast";
|
||||
import "@app/components/viewer/TextSelectionMenu.css";
|
||||
|
||||
export function TextSelectionMenu({
|
||||
function CopyIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.9"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<rect x="8.5" y="8.5" width="12" height="12" rx="2.5" />
|
||||
<path d="M5 15.5H4a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HighlightIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<rect x="2" y="2" width="20" height="20" rx="4.5" fill="#FACC15" />
|
||||
<path
|
||||
d="M12 5.8L7.6 17.5H9.6L10.6 14.7H13.4L14.4 17.5H16.4L12 5.8ZM11.3 12.8L12 10.4L12.7 12.8H11.3Z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StrikeoutIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<path
|
||||
d="M12 4.5L7.2 18H9.4L10.4 15.2H13.6L14.6 18H16.8L12 4.5ZM11.2 13L12 10.4L12.8 13H11.2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<line
|
||||
x1="3"
|
||||
y1="13.2"
|
||||
x2="21"
|
||||
y2="13.2"
|
||||
stroke="#EF4444"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UnderlineIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<path
|
||||
d="M12 3.8L7.2 17.2H9.4L10.4 14.4H13.6L14.6 17.2H16.8L12 3.8ZM11.2 12.2L12 9.6L12.8 12.2H11.2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<line
|
||||
x1="3.5"
|
||||
y1="20"
|
||||
x2="20.5"
|
||||
y2="20"
|
||||
stroke="#EF4444"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SquigglyIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<path
|
||||
d="M12 3.8L7.2 17.2H9.4L10.4 14.4H13.6L14.6 17.2H16.8L12 3.8ZM11.2 12.2L12 9.6L12.8 12.2H11.2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M3.5 20c1.2-1.2 2.3-1.2 3.5 0s2.3 1.2 3.5 0 2.3-1.2 3.5 0 2.3 1.2 3.5 0 2.3-1.2 3 0"
|
||||
stroke="#EF4444"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RedactIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="text-sel-redact-stripes"
|
||||
width="4"
|
||||
height="4"
|
||||
patternUnits="userSpaceOnUse"
|
||||
patternTransform="rotate(45)"
|
||||
>
|
||||
<line
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<path
|
||||
d="M7 3.5h10M12 3.5v4.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<rect
|
||||
x="2.5"
|
||||
y="9"
|
||||
width="19"
|
||||
height="12"
|
||||
rx="3"
|
||||
fill="url(#text-sel-redact-stripes)"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export type TextSelectionMenuProps = SelectionSelectionMenuProps & {
|
||||
documentId?: string;
|
||||
};
|
||||
|
||||
export function TextSelectionMenu(props: TextSelectionMenuProps) {
|
||||
const contextDocId = useActiveDocumentId();
|
||||
const documentId = props.documentId ?? contextDocId;
|
||||
|
||||
if (!documentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TextSelectionMenuInner {...props} documentId={documentId} />;
|
||||
}
|
||||
|
||||
function TextSelectionMenuInner({
|
||||
selected,
|
||||
menuWrapperProps,
|
||||
placement,
|
||||
}: SelectionSelectionMenuProps) {
|
||||
documentId,
|
||||
}: TextSelectionMenuProps & { documentId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { provides: selection } = useSelectionCapability();
|
||||
const { provides: annotationProvides } = useAnnotation(documentId ?? "");
|
||||
|
||||
const { handleToolSelectForced, setSidebarsVisible, setLeftPanelView } =
|
||||
useToolWorkflow();
|
||||
const {
|
||||
setRedactionMode,
|
||||
activateRedact,
|
||||
setRedactionConfig,
|
||||
redactionApiRef,
|
||||
} = useRedaction();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
} | null>(null);
|
||||
|
||||
const [linkPopoverOpen, setLinkPopoverOpen] = useState(false);
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
|
||||
const setRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
wrapperRef.current = node;
|
||||
@@ -54,13 +281,174 @@ export function TextSelectionMenu({
|
||||
}, [selected, showAbove]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
selection?.copyToClipboard();
|
||||
}, [selection]);
|
||||
if (documentId) {
|
||||
selection?.copyToClipboard(documentId);
|
||||
selection?.clear(documentId);
|
||||
alert({
|
||||
alertType: "neutral",
|
||||
title: t("common.copied", "Copied to clipboard"),
|
||||
durationMs: 2000,
|
||||
});
|
||||
}
|
||||
}, [selection, documentId, t]);
|
||||
|
||||
const openAnnotateUi = useCallback(() => {
|
||||
handleToolSelectForced?.("annotate");
|
||||
setSidebarsVisible?.(true);
|
||||
setLeftPanelView?.("toolContent");
|
||||
}, [handleToolSelectForced, setSidebarsVisible, setLeftPanelView]);
|
||||
|
||||
const createMarkupAnnotation = useCallback(
|
||||
(
|
||||
subtype:
|
||||
| PdfAnnotationSubtype.HIGHLIGHT
|
||||
| PdfAnnotationSubtype.STRIKEOUT
|
||||
| PdfAnnotationSubtype.UNDERLINE
|
||||
| PdfAnnotationSubtype.SQUIGGLY,
|
||||
color: string,
|
||||
blendMode?: PdfBlendMode,
|
||||
) => {
|
||||
if (!documentId) return;
|
||||
const selections = selection?.getFormattedSelection(documentId) ?? [];
|
||||
if (!selections.length) return;
|
||||
|
||||
const apply = (text?: string) => {
|
||||
for (const sel of selections) {
|
||||
annotationProvides?.createAnnotation(sel.pageIndex, {
|
||||
type: subtype,
|
||||
strokeColor: color,
|
||||
color,
|
||||
opacity: 1,
|
||||
...(blendMode !== undefined ? { blendMode } : {}),
|
||||
rect: sel.rect,
|
||||
segmentRects: sel.segmentRects,
|
||||
pageIndex: sel.pageIndex,
|
||||
created: new Date(),
|
||||
id: uuidV4(),
|
||||
...(text ? { custom: { text } } : {}),
|
||||
});
|
||||
}
|
||||
selection?.clear(documentId);
|
||||
navActions?.setHasUnsavedChanges(true);
|
||||
openAnnotateUi();
|
||||
};
|
||||
|
||||
const selTask = selection?.getSelectedText(documentId);
|
||||
if (selTask) {
|
||||
selTask.wait(
|
||||
(texts) => apply(texts.join("\n")),
|
||||
() => apply(),
|
||||
);
|
||||
} else {
|
||||
apply();
|
||||
}
|
||||
},
|
||||
[documentId, selection, annotationProvides, navActions, openAnnotateUi],
|
||||
);
|
||||
|
||||
const handleHighlight = useCallback(() => {
|
||||
createMarkupAnnotation(
|
||||
PdfAnnotationSubtype.HIGHLIGHT,
|
||||
"#FFCD45",
|
||||
PdfBlendMode.Multiply,
|
||||
);
|
||||
}, [createMarkupAnnotation]);
|
||||
|
||||
const handleStrikeout = useCallback(() => {
|
||||
createMarkupAnnotation(PdfAnnotationSubtype.STRIKEOUT, "#E44234");
|
||||
}, [createMarkupAnnotation]);
|
||||
|
||||
const handleUnderline = useCallback(() => {
|
||||
createMarkupAnnotation(PdfAnnotationSubtype.UNDERLINE, "#E44234");
|
||||
}, [createMarkupAnnotation]);
|
||||
|
||||
const handleSquiggly = useCallback(() => {
|
||||
createMarkupAnnotation(PdfAnnotationSubtype.SQUIGGLY, "#E44234");
|
||||
}, [createMarkupAnnotation]);
|
||||
|
||||
const handleAddLink = useCallback(
|
||||
(url: string) => {
|
||||
const uri = url.trim();
|
||||
if (!documentId || !uri) return;
|
||||
const selections = selection?.getFormattedSelection(documentId) ?? [];
|
||||
if (!selections.length) return;
|
||||
|
||||
for (const sel of selections) {
|
||||
annotationProvides?.createAnnotation(sel.pageIndex, {
|
||||
type: PdfAnnotationSubtype.LINK,
|
||||
id: `link-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
pageIndex: sel.pageIndex,
|
||||
rect: sel.rect,
|
||||
target: { type: "action", action: { type: PdfActionType.URI, uri } },
|
||||
created: new Date(),
|
||||
});
|
||||
}
|
||||
selection?.clear(documentId);
|
||||
setLinkPopoverOpen(false);
|
||||
setLinkUrl("");
|
||||
navActions?.setHasUnsavedChanges(true);
|
||||
openAnnotateUi();
|
||||
},
|
||||
[documentId, selection, annotationProvides, navActions, openAnnotateUi],
|
||||
);
|
||||
|
||||
const handleRedact = useCallback(() => {
|
||||
if (!documentId) return;
|
||||
const selections = selection?.getFormattedSelection(documentId) ?? [];
|
||||
for (const sel of selections) {
|
||||
annotationProvides?.createAnnotation(sel.pageIndex, {
|
||||
type: PdfAnnotationSubtype.REDACT,
|
||||
strokeColor: "#E44234",
|
||||
color: "#000000",
|
||||
overlayColor: "#000000",
|
||||
fillColor: "#000000",
|
||||
interiorColor: "#000000",
|
||||
backgroundColor: "#000000",
|
||||
opacity: 1,
|
||||
rect: sel.rect,
|
||||
segmentRects: sel.segmentRects,
|
||||
pageIndex: sel.pageIndex,
|
||||
created: new Date(),
|
||||
id: uuidV4(),
|
||||
});
|
||||
}
|
||||
selection?.clear(documentId);
|
||||
|
||||
// Bring the full Redact tool UI
|
||||
const manualConfig: RedactParameters = {
|
||||
...defaultParameters,
|
||||
mode: "manual",
|
||||
};
|
||||
setRedactionConfig?.(manualConfig);
|
||||
setRedactionMode?.(true);
|
||||
navActions?.setHasUnsavedChanges(true);
|
||||
navActions?.setToolAndWorkbench("redact", "viewer");
|
||||
setSidebarsVisible?.(true);
|
||||
setLeftPanelView?.("toolContent");
|
||||
setTimeout(() => {
|
||||
const currentType = redactionApiRef?.current?.getActiveType?.();
|
||||
if (currentType !== RedactionMode.Redact) {
|
||||
activateRedact?.();
|
||||
}
|
||||
}, 200);
|
||||
}, [
|
||||
documentId,
|
||||
selection,
|
||||
annotationProvides,
|
||||
setRedactionConfig,
|
||||
setRedactionMode,
|
||||
navActions,
|
||||
setSidebarsVisible,
|
||||
setLeftPanelView,
|
||||
redactionApiRef,
|
||||
activateRedact,
|
||||
]);
|
||||
|
||||
const portalContent =
|
||||
position &&
|
||||
createPortal(
|
||||
<div
|
||||
data-text-selection-menu
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: position.top,
|
||||
@@ -71,20 +459,137 @@ export function TextSelectionMenu({
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Tooltip label={t("viewer.copyText", "Copy")} withArrow>
|
||||
<ActionIcon
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
onClick={handleCopy}
|
||||
aria-label={t("viewer.copyText", "Copy")}
|
||||
style={{
|
||||
boxShadow: "0 2px 12px rgba(0, 0, 0, 0.25)",
|
||||
}}
|
||||
<div className="embedpdf-text-selection-menu">
|
||||
{/* 1. Copy */}
|
||||
<Tooltip label={t("viewer.copyText", "Copy")} withArrow>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-text-selection-btn"
|
||||
onClick={handleCopy}
|
||||
aria-label={t("viewer.copyText", "Copy")}
|
||||
>
|
||||
<CopyIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* 2. Highlight */}
|
||||
<Tooltip label={t("annotation.highlight", "Highlight")} withArrow>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-text-selection-btn"
|
||||
onClick={handleHighlight}
|
||||
aria-label={t("annotation.highlight", "Highlight")}
|
||||
>
|
||||
<HighlightIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* 3. Strikethrough */}
|
||||
<Tooltip label={t("annotation.strikeout", "Strikeout")} withArrow>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-text-selection-btn"
|
||||
onClick={handleStrikeout}
|
||||
aria-label={t("annotation.strikeout", "Strikeout")}
|
||||
>
|
||||
<StrikeoutIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* 4. Underline */}
|
||||
<Tooltip label={t("annotation.underline", "Underline")} withArrow>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-text-selection-btn"
|
||||
onClick={handleUnderline}
|
||||
aria-label={t("annotation.underline", "Underline")}
|
||||
>
|
||||
<UnderlineIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* 5. Squiggly */}
|
||||
<Tooltip label={t("annotation.squiggly", "Squiggly")} withArrow>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-text-selection-btn"
|
||||
onClick={handleSquiggly}
|
||||
aria-label={t("annotation.squiggly", "Squiggly")}
|
||||
>
|
||||
<SquigglyIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* 6. Link */}
|
||||
<Popover
|
||||
opened={linkPopoverOpen}
|
||||
onChange={setLinkPopoverOpen}
|
||||
position={showAbove ? "top" : "bottom"}
|
||||
withArrow
|
||||
shadow="md"
|
||||
transitionProps={{ duration: 0 }}
|
||||
>
|
||||
<ContentCopyIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Popover.Target>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-text-selection-btn"
|
||||
onClick={() => setLinkPopoverOpen((o) => !o)}
|
||||
aria-label={t("viewer.comments.addLink", "Add link")}
|
||||
>
|
||||
<Tooltip
|
||||
label={t("viewer.comments.addLink", "Add link")}
|
||||
withArrow
|
||||
disabled={linkPopoverOpen}
|
||||
>
|
||||
<span style={{ display: "inline-flex" }}>
|
||||
<LinkIcon />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
backgroundColor: "var(--mantine-color-body)",
|
||||
borderColor: "var(--mantine-color-default-border)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs" style={{ minWidth: 220 }}>
|
||||
<TextInput
|
||||
placeholder="https://..."
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && linkUrl.trim()) {
|
||||
handleAddLink(linkUrl);
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!linkUrl.trim()}
|
||||
onClick={() => handleAddLink(linkUrl)}
|
||||
>
|
||||
{t("viewer.comments.addLink", "Add link")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
{/* 7. Redact */}
|
||||
<Tooltip label={t("workbenchBar.redact", "Redact")} withArrow>
|
||||
<button
|
||||
type="button"
|
||||
className="embedpdf-text-selection-btn"
|
||||
onClick={handleRedact}
|
||||
aria-label={t("workbenchBar.redact", "Redact")}
|
||||
>
|
||||
<RedactIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -60,6 +60,7 @@ export default function ViewerAnnotationControls({
|
||||
const {
|
||||
setRedactionMode,
|
||||
activateRedact,
|
||||
deactivateRedact,
|
||||
setRedactionConfig,
|
||||
setRedactionsApplied,
|
||||
redactionApiRef,
|
||||
@@ -119,7 +120,14 @@ export default function ViewerAnnotationControls({
|
||||
setLeftPanelView("toolPicker");
|
||||
setRedactionMode(false);
|
||||
setActiveType(null);
|
||||
}, [navActions, setLeftPanelView, setRedactionMode, setActiveType]);
|
||||
deactivateRedact();
|
||||
}, [
|
||||
navActions,
|
||||
setLeftPanelView,
|
||||
setRedactionMode,
|
||||
setActiveType,
|
||||
deactivateRedact,
|
||||
]);
|
||||
|
||||
// Handle redaction mode toggle
|
||||
const handleRedactionToggle = async () => {
|
||||
|
||||
@@ -111,12 +111,16 @@ test("Ctrl+C copies selected text to the clipboard", async ({
|
||||
await dragSelectAcrossPage(page, firstPage);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.keyboard.press("Control+C");
|
||||
const isMac = process.platform === "darwin";
|
||||
await page.keyboard.press(isMac ? "Meta+c" : "Control+c");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const clipboardText = await page.evaluate(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
let clipboardText = await page.evaluate(() => navigator.clipboard.readText());
|
||||
if (!clipboardText.trim()) {
|
||||
await page.keyboard.press(isMac ? "Control+c" : "Meta+c");
|
||||
await page.waitForTimeout(500);
|
||||
clipboardText = await page.evaluate(() => navigator.clipboard.readText());
|
||||
}
|
||||
expect(clipboardText.trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -113,6 +113,12 @@ html[data-app-theme="light"] {
|
||||
--c-hero-dark: var(--p-zinc-950);
|
||||
--c-hero-dark-cta-text: var(--p-navy-700);
|
||||
|
||||
/* Always-dark floating PDF context/selection toolbars. Same in both themes. */
|
||||
--c-floating-menu-bg: var(--p-c-181c24);
|
||||
--c-floating-menu-text: var(--p-gray-250);
|
||||
--c-floating-danger: var(--p-red-500);
|
||||
--c-floating-danger-hover: var(--p-red-400);
|
||||
|
||||
/* Neutral solid badge fill (grey pill). */
|
||||
--c-neutral-fill: var(--p-gray-500);
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
--p-zinc-200: #a1a1aa;
|
||||
--p-zinc-100: #f4f4f5;
|
||||
--p-c-141416: #141416;
|
||||
--p-c-181c24: #181c24;
|
||||
--p-c-1a1a1d: #1a1a1d;
|
||||
--p-c-28282d: #28282d;
|
||||
--p-c-343439: #343439;
|
||||
|
||||
@@ -35,8 +35,12 @@ const Redact = (props: BaseToolProps) => {
|
||||
|
||||
// Navigation and redaction context
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const { setRedactionConfig, setRedactionMode, redactionConfig } =
|
||||
useRedaction();
|
||||
const {
|
||||
setRedactionConfig,
|
||||
setRedactionMode,
|
||||
deactivateRedact,
|
||||
redactionConfig,
|
||||
} = useRedaction();
|
||||
const { workbench } = useNavigationState();
|
||||
const hasOpenedViewer = useRef(false);
|
||||
|
||||
@@ -47,6 +51,14 @@ const Redact = (props: BaseToolProps) => {
|
||||
props,
|
||||
);
|
||||
|
||||
// Clean up redaction mode when tool unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setRedactionMode(false);
|
||||
deactivateRedact();
|
||||
};
|
||||
}, [setRedactionMode, deactivateRedact]);
|
||||
|
||||
// Get total file count from context (any files in workbench, not just selected)
|
||||
const { state: fileState } = useFileState();
|
||||
const hasAnyFiles = fileState.files.ids.length > 0;
|
||||
@@ -112,11 +124,20 @@ const Redact = (props: BaseToolProps) => {
|
||||
|
||||
// Reset viewer flag when mode changes back to automatic
|
||||
useEffect(() => {
|
||||
if (base.params.parameters.mode === "automatic") {
|
||||
if (
|
||||
base.params.parameters.mode === "automatic" &&
|
||||
redactionConfig?.mode !== "manual"
|
||||
) {
|
||||
hasOpenedViewer.current = false;
|
||||
setRedactionMode(false);
|
||||
deactivateRedact();
|
||||
}
|
||||
}, [base.params.parameters.mode, setRedactionMode]);
|
||||
}, [
|
||||
base.params.parameters.mode,
|
||||
redactionConfig?.mode,
|
||||
setRedactionMode,
|
||||
deactivateRedact,
|
||||
]);
|
||||
|
||||
const isExecuteDisabled = () => {
|
||||
if (base.params.parameters.mode === "manual") {
|
||||
|
||||
Reference in New Issue
Block a user