Compare commits

...
22 changed files with 1310 additions and 217 deletions
@@ -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;
+25 -4
View File
@@ -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") {