Compare commits

...
Author SHA1 Message Date
posthog-eu[bot] 12bc47d54c Add feedback to sign tool's saved-signature flow to stop rage-clicking
The saved-signature browse/delete/place controls gave too little feedback, so
users re-clicked them repeatedly (the icon-only Use/Remove buttons were the
top rage-click target on /sign, alongside Pause/Resume placement).

- Remove is async (a network round-trip for backend storage) but showed no
  loading state and stayed enabled, so repeated clicks fired several delete
  requests and, once one resolved, the next signature slid into the same card
  slot — looking like nothing happened. It now shows a spinner and disables the
  category controls while the delete is in flight.
- Use is an icon-only control with no confirmation; it now shows a transient
  "Selected" success state so the click clearly registers.
- The saved-signature carousel now wraps around so it is quicker to cycle.
- Pause/Resume placement gained descriptive tooltips and clearer paused-state
  copy explaining what the mode does and how to resume (button or Esc).
- Signing is applied client-side (endpoint: null), so completions never reached
  the shared execute path that emits editor_operation. Emit it on a successful
  apply so abandonment on this flow is measurable.

Generated-By: PostHog Code
Task-Id: 4cd0e9d7-9785-4553-83cf-c6a6ca9c270b
2026-07-15 11:17:47 +00:00
5 changed files with 446 additions and 542 deletions
@@ -9403,7 +9403,7 @@ canvas = "After drawing your signature in the canvas, close the modal then click
image = "After uploading your signature image above, click anywhere on the PDF to place it."
noSignature = "Create a signature above to enable placement tools."
paused = "Placement paused"
resumeHint = "Resume placement to click and add your signature."
resumeHint = "Placement is paused — clicking the PDF won't add your signature. Click \"Resume placement\" below (or press Esc) to continue."
saved = "Select a saved signature above, then click anywhere on the PDF to place it."
text = "After entering your name above, click anywhere on the PDF to place your signature."
title = "How to add signature"
@@ -9411,8 +9411,10 @@ title = "How to add signature"
[sign.mode]
move = "Move Signature"
pause = "Pause placement"
pauseHint = "Stop placing signatures so you can scroll and click the PDF without adding one."
place = "Place Signature"
resume = "Resume placement"
resumeHint = "Turn placement back on so clicking the PDF adds your signature."
[sign.results]
title = "Signature Results"
@@ -9445,6 +9447,7 @@ sharedHeading = "Shared Signatures"
tempStorageDescription = "Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers."
tempStorageTitle = "Temporary browser storage"
use = "Use signature"
used = "Selected — click the PDF to place it"
[sign.saved.status]
saved = "Saved"
@@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
@@ -26,8 +33,8 @@ interface SavedSignaturesSectionProps {
maxLimit: number;
storageType?: StorageType | null;
isAdmin?: boolean;
onUseSignature: (signature: SavedSignature) => void;
onDeleteSignature: (signature: SavedSignature) => void;
onUseSignature: (signature: SavedSignature) => void | Promise<void>;
onDeleteSignature: (signature: SavedSignature) => void | Promise<void>;
onRenameSignature: (id: string, label: string) => void;
translationScope?: string;
}
@@ -38,6 +45,335 @@ const typeBadgeColor: Record<SavedSignatureType, string> = {
text: "grape",
};
// How long the "Selected ✓" confirmation stays on the Use button after a click.
const USE_FEEDBACK_DURATION = 1500;
type TranslateFn = (
key: string,
defaultValue: string,
options?: Record<string, unknown>,
) => string;
const renderPreview = (signature: SavedSignature) => {
if (signature.type === "text") {
return (
<Box
component="div"
style={{
fontFamily: signature.fontFamily,
fontSize: `${signature.fontSize}px`,
color: signature.textColor,
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "120px",
borderRadius: "0.5rem",
backgroundColor: "#ffffff",
padding: "0.5rem",
textAlign: "center",
overflow: "hidden",
}}
>
<Text
size="lg"
style={{
fontFamily: signature.fontFamily,
color: signature.textColor,
whiteSpace: "nowrap",
}}
>
{signature.signerName}
</Text>
</Box>
);
}
return (
<Box
component="div"
style={{
backgroundColor: "#ffffff",
borderRadius: "0.5rem",
height: "120px",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0.5rem",
}}
>
<Box
component="img"
src={signature.dataUrl}
alt={signature.label}
style={{
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain",
}}
/>
</Box>
);
};
const typeLabel = (type: SavedSignatureType, translate: TranslateFn) => {
switch (type) {
case "canvas":
return translate("saved.type.canvas", "Drawing");
case "image":
return translate("saved.type.image", "Upload");
case "text":
return translate("saved.type.text", "Text");
default:
return type;
}
};
interface SignatureCategoryProps {
header: ReactNode;
signatures: SavedSignature[];
disabled: boolean;
allowDelete: boolean;
translate: TranslateFn;
onUseSignature: (signature: SavedSignature) => void | Promise<void>;
onDeleteSignature: (signature: SavedSignature) => void | Promise<void>;
onRenameSignature: (id: string, label: string) => void;
}
// A single category (personal / shared / browser-storage) carousel. Owns its
// carousel index, per-signature label drafts, and the transient in-flight
// feedback that stops users re-clicking the icon-only Use/Remove controls.
const SignatureCategory = ({
header,
signatures,
disabled,
allowDelete,
translate,
onUseSignature,
onDeleteSignature,
onRenameSignature,
}: SignatureCategoryProps) => {
const [activeIndex, setActiveIndex] = useState(0);
const [labelDrafts, setLabelDrafts] = useState<Record<string, string>>({});
const [deletingId, setDeletingId] = useState<string | null>(null);
const [justUsedId, setJustUsedId] = useState<string | null>(null);
const useFeedbackTimer = useRef<number | null>(null);
const total = signatures.length;
const canCycle = total > 1;
// Keep the active index in range as signatures are added/removed.
useEffect(() => {
setActiveIndex((prev) => Math.min(prev, Math.max(total - 1, 0)));
}, [total]);
useEffect(() => {
setLabelDrafts((prev) => {
const nextDrafts: Record<string, string> = {};
signatures.forEach((sig) => {
nextDrafts[sig.id] = prev[sig.id] ?? sig.label ?? "";
});
return nextDrafts;
});
}, [signatures]);
useEffect(
() => () => {
if (useFeedbackTimer.current !== null) {
window.clearTimeout(useFeedbackTimer.current);
}
},
[],
);
const active = signatures[activeIndex];
const goPrev = useCallback(() => {
setActiveIndex((prev) => (prev - 1 + total) % total);
}, [total]);
const goNext = useCallback(() => {
setActiveIndex((prev) => (prev + 1) % total);
}, [total]);
const handleUse = useCallback(
async (signature: SavedSignature) => {
// Immediate, visible confirmation so the icon-only control doesn't feel dead.
if (useFeedbackTimer.current !== null) {
window.clearTimeout(useFeedbackTimer.current);
}
setJustUsedId(signature.id);
useFeedbackTimer.current = window.setTimeout(() => {
setJustUsedId((current) => (current === signature.id ? null : current));
useFeedbackTimer.current = null;
}, USE_FEEDBACK_DURATION);
try {
await onUseSignature(signature);
} catch (error) {
console.error("[SavedSignatures] Failed to use signature:", error);
}
},
[onUseSignature],
);
const handleDelete = useCallback(
async (signature: SavedSignature) => {
// Guard against the rapid re-clicks that were firing several delete
// requests for the same signature; show a spinner while it resolves.
if (deletingId) {
return;
}
setDeletingId(signature.id);
try {
await onDeleteSignature(signature);
} catch (error) {
console.error("[SavedSignatures] Failed to delete signature:", error);
} finally {
setDeletingId(null);
}
},
[deletingId, onDeleteSignature],
);
const handleLabelChange = (
event: React.ChangeEvent<HTMLInputElement>,
signature: SavedSignature,
) => {
const { value } = event.currentTarget;
setLabelDrafts((prev) => ({ ...prev, [signature.id]: value }));
};
const handleLabelBlur = (signature: SavedSignature) => {
const nextValue = labelDrafts[signature.id]?.trim() ?? "";
if (!nextValue || nextValue === signature.label) {
setLabelDrafts((prev) => ({ ...prev, [signature.id]: signature.label }));
return;
}
onRenameSignature(signature.id, nextValue);
};
const handleLabelKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>,
signature: SavedSignature,
) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
if (event.key === "Escape") {
setLabelDrafts((prev) => ({ ...prev, [signature.id]: signature.label }));
event.currentTarget.blur();
}
};
if (!active) {
return null;
}
const isDeleting = deletingId === active.id;
const isJustUsed = justUsedId === active.id;
const controlsDisabled = disabled || Boolean(deletingId);
return (
<Stack gap="xs">
{header}
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{translate("saved.carouselPosition", "{{current}} of {{total}}", {
current: activeIndex + 1,
total,
})}
</Text>
<Group gap={4}>
<ActionIcon
variant="secondary"
aria-label={translate("saved.prev", "Previous")}
onClick={goPrev}
disabled={controlsDisabled || !canCycle}
>
<LocalIcon icon="chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="secondary"
aria-label={translate("saved.next", "Next")}
onClick={goNext}
disabled={controlsDisabled || !canCycle}
>
<LocalIcon icon="chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
<Card withBorder padding="sm">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge color={typeBadgeColor[active.type]} variant="light">
{typeLabel(active.type, translate)}
</Badge>
<Group gap="xs">
<Tooltip
label={
isJustUsed
? translate(
"saved.used",
"Selected — click the PDF to place it",
)
: translate("saved.use", "Use signature")
}
>
<ActionIcon
variant="tertiary"
accent={isJustUsed ? "success" : undefined}
aria-label={translate("saved.use", "Use signature")}
onClick={() => handleUse(active)}
disabled={controlsDisabled}
>
<LocalIcon
icon={
isJustUsed
? "check-circle-rounded"
: "check-circle-outline-rounded"
}
width={18}
height={18}
/>
</ActionIcon>
</Tooltip>
{allowDelete && (
<Tooltip label={translate("saved.delete", "Remove")}>
<ActionIcon
variant="tertiary"
accent="danger"
aria-label={translate("saved.delete", "Remove")}
loading={isDeleting}
onClick={() => handleDelete(active)}
disabled={controlsDisabled}
>
<LocalIcon
icon="delete-outline-rounded"
width={18}
height={18}
/>
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
{renderPreview(active)}
<TextInput
label={translate("saved.label", "Label")}
value={labelDrafts[active.id] ?? active.label}
onChange={(event) => handleLabelChange(event, active)}
onBlur={() => handleLabelBlur(active)}
onKeyDown={(event) => handleLabelKeyDown(event, active)}
disabled={disabled}
/>
</Stack>
</Card>
</Stack>
);
};
export const SavedSignaturesSection = ({
signatures,
disabled = false,
@@ -51,12 +387,11 @@ export const SavedSignaturesSection = ({
translationScope = "sign",
}: SavedSignaturesSectionProps) => {
const { t } = useTranslation();
const translate = useCallback(
(key: string, defaultValue: string, options?: Record<string, unknown>) =>
const translate = useCallback<TranslateFn>(
(key, defaultValue, options) =>
t(`${translationScope}.${key}`, { defaultValue, ...options }),
[t, translationScope],
);
const [labelDrafts, setLabelDrafts] = useState<Record<string, string>>({});
// Group signatures by scope
const groupedSignatures = useMemo(() => {
@@ -68,113 +403,6 @@ export const SavedSignaturesSection = ({
return { personal, shared, localStorage };
}, [signatures]);
// Separate carousel state for each category
const [activePersonalIndex, setActivePersonalIndex] = useState(0);
const [activeSharedIndex, setActiveSharedIndex] = useState(0);
const [activeLocalStorageIndex, setActiveLocalStorageIndex] = useState(0);
const activePersonalSignature =
groupedSignatures.personal[activePersonalIndex];
const activeSharedSignature = groupedSignatures.shared[activeSharedIndex];
const activeLocalStorageSignature =
groupedSignatures.localStorage[activeLocalStorageIndex];
const onUseSignatureRef = useRef(onUseSignature);
useEffect(() => {
onUseSignatureRef.current = onUseSignature;
}, [onUseSignature]);
useEffect(() => {
setLabelDrafts((prev) => {
const nextDrafts: Record<string, string> = {};
signatures.forEach((sig) => {
nextDrafts[sig.id] = prev[sig.id] ?? sig.label ?? "";
});
return nextDrafts;
});
}, [signatures]);
// Reset carousel indices when categories change
useEffect(() => {
setActivePersonalIndex((prev) =>
Math.min(prev, Math.max(groupedSignatures.personal.length - 1, 0)),
);
}, [groupedSignatures.personal.length]);
useEffect(() => {
setActiveSharedIndex((prev) =>
Math.min(prev, Math.max(groupedSignatures.shared.length - 1, 0)),
);
}, [groupedSignatures.shared.length]);
useEffect(() => {
setActiveLocalStorageIndex((prev) =>
Math.min(prev, Math.max(groupedSignatures.localStorage.length - 1, 0)),
);
}, [groupedSignatures.localStorage.length]);
const renderPreview = (signature: SavedSignature) => {
if (signature.type === "text") {
return (
<Box
component="div"
style={{
fontFamily: signature.fontFamily,
fontSize: `${signature.fontSize}px`,
color: signature.textColor,
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "120px",
borderRadius: "0.5rem",
backgroundColor: "#ffffff",
padding: "0.5rem",
textAlign: "center",
overflow: "hidden",
}}
>
<Text
size="lg"
style={{
fontFamily: signature.fontFamily,
color: signature.textColor,
whiteSpace: "nowrap",
}}
>
{signature.signerName}
</Text>
</Box>
);
}
return (
<Box
component="div"
style={{
backgroundColor: "#ffffff",
borderRadius: "0.5rem",
height: "120px",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0.5rem",
}}
>
<Box
component="img"
src={signature.dataUrl}
alt={signature.label}
style={{
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain",
}}
/>
</Box>
);
};
const emptyState = (
<Card withBorder>
<Stack gap="xs">
@@ -192,49 +420,6 @@ export const SavedSignaturesSection = ({
</Card>
);
const typeLabel = (type: SavedSignatureType) => {
switch (type) {
case "canvas":
return translate("saved.type.canvas", "Drawing");
case "image":
return translate("saved.type.image", "Upload");
case "text":
return translate("saved.type.text", "Text");
default:
return type;
}
};
const handleLabelBlur = (signature: SavedSignature) => {
const nextValue = labelDrafts[signature.id]?.trim() ?? "";
if (!nextValue || nextValue === signature.label) {
setLabelDrafts((prev) => ({ ...prev, [signature.id]: signature.label }));
return;
}
onRenameSignature(signature.id, nextValue);
};
const handleLabelChange = (
event: React.ChangeEvent<HTMLInputElement>,
signature: SavedSignature,
) => {
const { value } = event.currentTarget;
setLabelDrafts((prev) => ({ ...prev, [signature.id]: value }));
};
const handleLabelKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>,
signature: SavedSignature,
) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
if (event.key === "Escape") {
setLabelDrafts((prev) => ({ ...prev, [signature.id]: signature.label }));
event.currentTarget.blur();
}
};
return (
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
@@ -273,267 +458,70 @@ export const SavedSignaturesSection = ({
) : (
<Stack gap="md">
{/* Personal Signatures */}
{groupedSignatures.personal.length > 0 && activePersonalSignature && (
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon="person-rounded" width={18} height={18} />
<Text fw={600} size="sm">
{translate("saved.personalHeading", "Personal Signatures")}
</Text>
</Group>
<Text size="xs" c="dimmed">
{translate(
"saved.personalDescription",
"Only you can see these signatures.",
)}
</Text>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{translate(
"saved.carouselPosition",
"{{current}} of {{total}}",
{
current: activePersonalIndex + 1,
total: groupedSignatures.personal.length,
},
)}
</Text>
<Group gap={4}>
<ActionIcon
variant="secondary"
aria-label={translate("saved.prev", "Previous")}
onClick={() =>
setActivePersonalIndex((prev) => Math.max(0, prev - 1))
}
disabled={disabled || activePersonalIndex === 0}
>
<LocalIcon
icon="chevron-left-rounded"
width={18}
height={18}
/>
</ActionIcon>
<ActionIcon
variant="secondary"
aria-label={translate("saved.next", "Next")}
onClick={() =>
setActivePersonalIndex((prev) =>
Math.min(
groupedSignatures.personal.length - 1,
prev + 1,
),
)
}
disabled={
disabled ||
activePersonalIndex >=
groupedSignatures.personal.length - 1
}
>
<LocalIcon
icon="chevron-right-rounded"
width={18}
height={18}
/>
</ActionIcon>
</Group>
</Group>
<Card withBorder padding="sm">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge
color={typeBadgeColor[activePersonalSignature.type]}
variant="light"
>
{typeLabel(activePersonalSignature.type)}
</Badge>
<Group gap="xs">
<ActionIcon
variant="tertiary"
aria-label={t("sign.saved.use", "Use signature")}
onClick={() => onUseSignature(activePersonalSignature)}
disabled={disabled}
>
<LocalIcon
icon="check-circle-outline-rounded"
width={18}
height={18}
/>
</ActionIcon>
<Tooltip label={translate("saved.delete", "Remove")}>
<ActionIcon
variant="tertiary"
accent="danger"
aria-label={translate("saved.delete", "Remove")}
onClick={() =>
onDeleteSignature(activePersonalSignature)
}
disabled={disabled}
>
<LocalIcon
icon="delete-outline-rounded"
width={18}
height={18}
/>
</ActionIcon>
</Tooltip>
</Group>
{groupedSignatures.personal.length > 0 && (
<SignatureCategory
header={
<>
<Group gap="xs">
<LocalIcon icon="person-rounded" width={18} height={18} />
<Text fw={600} size="sm">
{translate(
"saved.personalHeading",
"Personal Signatures",
)}
</Text>
</Group>
{renderPreview(activePersonalSignature)}
<TextInput
label={translate("saved.label", "Label")}
value={
labelDrafts[activePersonalSignature.id] ??
activePersonalSignature.label
}
onChange={(event) =>
handleLabelChange(event, activePersonalSignature)
}
onBlur={() => handleLabelBlur(activePersonalSignature)}
onKeyDown={(event) =>
handleLabelKeyDown(event, activePersonalSignature)
}
disabled={disabled}
/>
</Stack>
</Card>
</Stack>
<Text size="xs" c="dimmed">
{translate(
"saved.personalDescription",
"Only you can see these signatures.",
)}
</Text>
</>
}
signatures={groupedSignatures.personal}
disabled={disabled}
allowDelete
translate={translate}
onUseSignature={onUseSignature}
onDeleteSignature={onDeleteSignature}
onRenameSignature={onRenameSignature}
/>
)}
{/* Shared Signatures */}
{groupedSignatures.shared.length > 0 && activeSharedSignature && (
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon="groups-rounded" width={18} height={18} />
<Text fw={600} size="sm">
{translate("saved.sharedHeading", "Shared Signatures")}
</Text>
</Group>
<Text size="xs" c="dimmed">
{translate(
"saved.sharedDescription",
"All users can see and use these signatures.",
)}
</Text>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{translate(
"saved.carouselPosition",
"{{current}} of {{total}}",
{
current: activeSharedIndex + 1,
total: groupedSignatures.shared.length,
},
)}
</Text>
<Group gap={4}>
<ActionIcon
variant="secondary"
aria-label={translate("saved.prev", "Previous")}
onClick={() =>
setActiveSharedIndex((prev) => Math.max(0, prev - 1))
}
disabled={disabled || activeSharedIndex === 0}
>
<LocalIcon
icon="chevron-left-rounded"
width={18}
height={18}
/>
</ActionIcon>
<ActionIcon
variant="secondary"
aria-label={translate("saved.next", "Next")}
onClick={() =>
setActiveSharedIndex((prev) =>
Math.min(groupedSignatures.shared.length - 1, prev + 1),
)
}
disabled={
disabled ||
activeSharedIndex >= groupedSignatures.shared.length - 1
}
>
<LocalIcon
icon="chevron-right-rounded"
width={18}
height={18}
/>
</ActionIcon>
</Group>
</Group>
<Card withBorder padding="sm">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge
color={typeBadgeColor[activeSharedSignature.type]}
variant="light"
>
{typeLabel(activeSharedSignature.type)}
</Badge>
<Group gap="xs">
<ActionIcon
variant="tertiary"
aria-label={t("sign.saved.use", "Use signature")}
onClick={() => onUseSignature(activeSharedSignature)}
disabled={disabled}
>
<LocalIcon
icon="check-circle-outline-rounded"
width={18}
height={18}
/>
</ActionIcon>
{isAdmin && (
<Tooltip label={translate("saved.delete", "Remove")}>
<ActionIcon
variant="tertiary"
accent="danger"
aria-label={translate("saved.delete", "Remove")}
onClick={() =>
onDeleteSignature(activeSharedSignature)
}
disabled={disabled}
>
<LocalIcon
icon="delete-outline-rounded"
width={18}
height={18}
/>
</ActionIcon>
</Tooltip>
)}
</Group>
{groupedSignatures.shared.length > 0 && (
<SignatureCategory
header={
<>
<Group gap="xs">
<LocalIcon icon="groups-rounded" width={18} height={18} />
<Text fw={600} size="sm">
{translate("saved.sharedHeading", "Shared Signatures")}
</Text>
</Group>
{renderPreview(activeSharedSignature)}
<TextInput
label={translate("saved.label", "Label")}
value={
labelDrafts[activeSharedSignature.id] ??
activeSharedSignature.label
}
onChange={(event) =>
handleLabelChange(event, activeSharedSignature)
}
onBlur={() => handleLabelBlur(activeSharedSignature)}
onKeyDown={(event) =>
handleLabelKeyDown(event, activeSharedSignature)
}
disabled={disabled}
/>
</Stack>
</Card>
</Stack>
<Text size="xs" c="dimmed">
{translate(
"saved.sharedDescription",
"All users can see and use these signatures.",
)}
</Text>
</>
}
signatures={groupedSignatures.shared}
disabled={disabled}
allowDelete={isAdmin}
translate={translate}
onUseSignature={onUseSignature}
onDeleteSignature={onDeleteSignature}
onRenameSignature={onRenameSignature}
/>
)}
{/* Browser Storage (localStorage) - Temporary */}
{groupedSignatures.localStorage.length > 0 &&
activeLocalStorageSignature && (
<Stack gap="xs">
{groupedSignatures.localStorage.length > 0 && (
<SignatureCategory
header={
<Alert
color="blue"
title={translate(
@@ -548,126 +536,16 @@ export const SavedSignaturesSection = ({
)}
</Text>
</Alert>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{translate(
"saved.carouselPosition",
"{{current}} of {{total}}",
{
current: activeLocalStorageIndex + 1,
total: groupedSignatures.localStorage.length,
},
)}
</Text>
<Group gap={4}>
<ActionIcon
variant="secondary"
aria-label={translate("saved.prev", "Previous")}
onClick={() =>
setActiveLocalStorageIndex((prev) =>
Math.max(0, prev - 1),
)
}
disabled={disabled || activeLocalStorageIndex === 0}
>
<LocalIcon
icon="chevron-left-rounded"
width={18}
height={18}
/>
</ActionIcon>
<ActionIcon
variant="secondary"
aria-label={translate("saved.next", "Next")}
onClick={() =>
setActiveLocalStorageIndex((prev) =>
Math.min(
groupedSignatures.localStorage.length - 1,
prev + 1,
),
)
}
disabled={
disabled ||
activeLocalStorageIndex >=
groupedSignatures.localStorage.length - 1
}
>
<LocalIcon
icon="chevron-right-rounded"
width={18}
height={18}
/>
</ActionIcon>
</Group>
</Group>
<Card withBorder padding="sm">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge
color={typeBadgeColor[activeLocalStorageSignature.type]}
variant="light"
>
{typeLabel(activeLocalStorageSignature.type)}
</Badge>
<Group gap="xs">
<ActionIcon
variant="tertiary"
aria-label={t("sign.saved.use", "Use signature")}
onClick={() =>
onUseSignature(activeLocalStorageSignature)
}
disabled={disabled}
>
<LocalIcon
icon="check-circle-outline-rounded"
width={18}
height={18}
/>
</ActionIcon>
<Tooltip label={translate("saved.delete", "Remove")}>
<ActionIcon
variant="tertiary"
accent="danger"
aria-label={translate("saved.delete", "Remove")}
onClick={() =>
onDeleteSignature(activeLocalStorageSignature)
}
disabled={disabled}
>
<LocalIcon
icon="delete-outline-rounded"
width={18}
height={18}
/>
</ActionIcon>
</Tooltip>
</Group>
</Group>
{renderPreview(activeLocalStorageSignature)}
<TextInput
label={translate("saved.label", "Label")}
value={
labelDrafts[activeLocalStorageSignature.id] ??
activeLocalStorageSignature.label
}
onChange={(event) =>
handleLabelChange(event, activeLocalStorageSignature)
}
onBlur={() =>
handleLabelBlur(activeLocalStorageSignature)
}
onKeyDown={(event) =>
handleLabelKeyDown(event, activeLocalStorageSignature)
}
disabled={disabled}
/>
</Stack>
</Card>
</Stack>
)}
}
signatures={groupedSignatures.localStorage}
disabled={disabled}
allowDelete
translate={translate}
onUseSignature={onUseSignature}
onDeleteSignature={onDeleteSignature}
onRenameSignature={onRenameSignature}
/>
)}
</Stack>
)}
</Stack>
@@ -378,9 +378,7 @@ const SignSettings = ({
);
const handleDeleteSavedSignature = useCallback(
(signature: SavedSignature) => {
removeSignature(signature.id);
},
(signature: SavedSignature) => removeSignature(signature.id),
[removeSignature],
);
@@ -1076,7 +1074,7 @@ const SignSettings = ({
? placementInstructions()
: translate(
"instructions.resumeHint",
"Resume placement to click and add your signature.",
'Placement is paused — clicking the PDF won\'t add your signature. Click "Resume placement" below (or press Esc) to continue.',
),
}
: {
@@ -1120,7 +1118,12 @@ const SignSettings = ({
const placementToggleControl =
onActivateSignaturePlacement || onDeactivateSignature ? (
isPlacementMode ? (
<Tooltip label={translate("mode.pause", "Pause placement")}>
<Tooltip
label={translate(
"mode.pauseHint",
"Stop placing signatures so you can scroll and click the PDF without adding one.",
)}
>
<Button
variant="secondary"
aria-label={translate("mode.pause", "Pause placement")}
@@ -1134,7 +1137,12 @@ const SignSettings = ({
</Button>
</Tooltip>
) : (
<Tooltip label={translate("mode.resume", "Resume placement")}>
<Tooltip
label={translate(
"mode.resumeHint",
"Turn placement back on so clicking the PDF adds your signature.",
)}
>
<Button
variant="secondary"
aria-label={translate("mode.resume", "Resume placement")}
@@ -18,6 +18,7 @@ import { useSignature } from "@app/contexts/SignatureContext";
import { useFileContext } from "@app/contexts/FileContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { flattenSignatures } from "@app/utils/signatureFlattening";
import { trackEditorOperation } from "@app/services/analytics";
import SharedSigningLauncher from "@app/components/shared/signing/SharedSigningLauncher";
import { SuggestedToolsSection } from "@app/components/tools/shared/SuggestedToolsSection";
import { useGroupSigningEnabled } from "@app/hooks/useGroupSigningEnabled";
@@ -179,6 +180,12 @@ export const createStampTool = (config: StampToolConfig) => {
setSignaturesApplied(true);
handleDeactivateSignature();
// Signing is applied client-side (endpoint: null in useSignOperation),
// so it never reaches the shared execute path that emits
// editor_operation. Emit it here so completions of this flow are
// measurable rather than invisible.
trackEditorOperation(toolId, 1);
const hasSignatureReady = (() => {
const params = base.params.parameters;
switch (params.signatureType) {
@@ -374,9 +374,7 @@ const SignSettings = ({
);
const handleDeleteSavedSignature = useCallback(
(signature: SavedSignature) => {
removeSignature(signature.id);
},
(signature: SavedSignature) => removeSignature(signature.id),
[removeSignature],
);
@@ -1108,7 +1106,7 @@ const SignSettings = ({
? placementInstructions()
: translate(
"instructions.resumeHint",
"Resume placement to click and add your signature.",
'Placement is paused — clicking the PDF won\'t add your signature. Click "Resume placement" below (or press Esc) to continue.',
),
}
: {
@@ -1152,7 +1150,12 @@ const SignSettings = ({
const placementToggleControl =
onActivateSignaturePlacement || onDeactivateSignature ? (
isPlacementMode ? (
<Tooltip label={translate("mode.pause", "Pause placement")}>
<Tooltip
label={translate(
"mode.pauseHint",
"Stop placing signatures so you can scroll and click the PDF without adding one.",
)}
>
<Button
variant="secondary"
aria-label={translate("mode.pause", "Pause placement")}
@@ -1166,7 +1169,12 @@ const SignSettings = ({
</Button>
</Tooltip>
) : (
<Tooltip label={translate("mode.resume", "Resume placement")}>
<Tooltip
label={translate(
"mode.resumeHint",
"Turn placement back on so clicking the PDF adds your signature.",
)}
>
<Button
variant="secondary"
aria-label={translate("mode.resume", "Resume placement")}