mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e25f180852 | ||
|
|
1b9e208541 | ||
|
|
cb3c235c91 | ||
|
|
cfa4df234f | ||
|
|
32d483ffe2 | ||
|
|
2a35b49521 | ||
|
|
16045dc5bf | ||
|
|
da623a5e64 | ||
|
|
1bf6d55112 | ||
|
|
0632a397f2 | ||
|
|
ab010a4f97 | ||
|
|
4cec23fe9f | ||
|
|
4179a40544 | ||
|
|
9a8c67086e | ||
|
|
bc87093588 | ||
|
|
a8bd5e61f3 | ||
|
|
f981986681 | ||
|
|
982991689e | ||
|
|
baa00ba186 | ||
|
|
0456434040 | ||
|
|
285a96c342 | ||
|
|
08c326d745 | ||
|
|
4100e63afe | ||
|
|
6a27923af1 | ||
|
|
8549176f4a | ||
|
|
1f511635cf | ||
|
|
058e7bd509 | ||
|
|
1ed61c7d9d | ||
|
|
3f5717b5ad | ||
|
|
3b9113b9a6 | ||
|
|
6dc446a39e | ||
|
|
c24370c28a | ||
|
|
2a82c9ae19 | ||
|
|
b70c7b41e3 |
@@ -226,6 +226,8 @@ title = "How to add images"
|
||||
move = "Move Image"
|
||||
pause = "Pause placement"
|
||||
place = "Place Image"
|
||||
placeMultiple = "Stay in placement mode after each placement"
|
||||
placeMultipleDesc = "Place several images in a row instead of exiting after the first."
|
||||
resume = "Resume placement"
|
||||
|
||||
[addImage.results]
|
||||
@@ -442,6 +444,8 @@ title = "How to add text"
|
||||
move = "Move Text"
|
||||
pause = "Pause placement"
|
||||
place = "Place Text"
|
||||
placeMultiple = "Stay in placement mode after each placement"
|
||||
placeMultipleDesc = "Place several text stamps in a row instead of exiting after the first."
|
||||
resume = "Resume placement"
|
||||
|
||||
[addText.results]
|
||||
@@ -10671,6 +10675,8 @@ title = "Draw on your phone"
|
||||
move = "Move Signature"
|
||||
pause = "Pause placement"
|
||||
place = "Place Signature"
|
||||
placeMultiple = "Stay in placement mode after each placement"
|
||||
placeMultipleDesc = "Place several signatures in a row instead of exiting after the first."
|
||||
resume = "Resume placement"
|
||||
|
||||
[sign.results]
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Tooltip,
|
||||
Group,
|
||||
Box,
|
||||
Checkbox,
|
||||
} from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { SegmentedControl } from "@app/ui/SegmentedControl";
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
AddSignatureResult,
|
||||
} from "@app/hooks/tools/sign/useSavedSignatures";
|
||||
import { SavedSignaturesSection } from "@app/components/tools/sign/SavedSignaturesSection";
|
||||
import { shouldPausePlacementAfterExit } from "@app/components/tools/sign/placementMode";
|
||||
import MobileSignatureModal, {
|
||||
type MobileSignaturePayload,
|
||||
} from "@app/components/tools/sign/MobileSignatureModal";
|
||||
@@ -95,7 +97,13 @@ const SignSettings = ({
|
||||
defaultSignatureSource,
|
||||
}: SignSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { isPlacementMode, signaturesApplied, historyApiRef } = useSignature();
|
||||
const {
|
||||
isPlacementMode,
|
||||
signaturesApplied,
|
||||
historyApiRef,
|
||||
placeMultiple,
|
||||
setPlaceMultiple,
|
||||
} = useSignature();
|
||||
const { activeFileIndex } = useViewer();
|
||||
const [historyAvailability, setHistoryAvailability] = useState({
|
||||
canUndo: false,
|
||||
@@ -897,6 +905,34 @@ const SignSettings = ({
|
||||
onParameterChange,
|
||||
]);
|
||||
|
||||
// After a single placement (when "place multiple" is off) the
|
||||
// SignatureAPIBridge drops out of placement mode. Without pausing here, the
|
||||
// auto-activate effect below would immediately pull the user back into
|
||||
// placement mode - so single placement would never stick and the "place
|
||||
// multiple" checkbox would appear to do nothing.
|
||||
const wasPlacementModeRef = useRef(isPlacementMode);
|
||||
useEffect(() => {
|
||||
const shouldPause = shouldPausePlacementAfterExit({
|
||||
wasInPlacementMode: wasPlacementModeRef.current,
|
||||
isInPlacementMode: isPlacementMode,
|
||||
placeMultiple,
|
||||
signaturesApplied,
|
||||
placementEnabled: shouldEnablePlacement,
|
||||
alreadyPaused: isPlacementManuallyPaused,
|
||||
});
|
||||
wasPlacementModeRef.current = isPlacementMode;
|
||||
|
||||
if (shouldPause) {
|
||||
setPlacementManuallyPaused(true);
|
||||
}
|
||||
}, [
|
||||
isPlacementMode,
|
||||
placeMultiple,
|
||||
signaturesApplied,
|
||||
shouldEnablePlacement,
|
||||
isPlacementManuallyPaused,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldEnablePlacement) {
|
||||
if (isPlacementMode) {
|
||||
@@ -1312,6 +1348,20 @@ const SignSettings = ({
|
||||
<Box style={{ marginLeft: "auto" }}>{placementToggleControl}</Box>
|
||||
</Group>
|
||||
|
||||
<Checkbox
|
||||
checked={placeMultiple}
|
||||
onChange={(event) => setPlaceMultiple(event.currentTarget.checked)}
|
||||
label={translate(
|
||||
"mode.placeMultiple",
|
||||
"Stay in placement mode after each placement",
|
||||
)}
|
||||
description={translate(
|
||||
"mode.placeMultipleDesc",
|
||||
"Place several stamps in a row instead of exiting after the first.",
|
||||
)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Alert color={placementAlert.color} title={placementAlert.title}>
|
||||
<Text size="sm">{placementAlert.message}</Text>
|
||||
</Alert>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldPausePlacementAfterExit } from "@app/components/tools/sign/placementMode";
|
||||
|
||||
const baseParams = {
|
||||
wasInPlacementMode: true,
|
||||
isInPlacementMode: false,
|
||||
placeMultiple: false,
|
||||
signaturesApplied: false,
|
||||
placementEnabled: true,
|
||||
alreadyPaused: false,
|
||||
};
|
||||
|
||||
describe("shouldPausePlacementAfterExit", () => {
|
||||
it("pauses after a single placement exits placement mode", () => {
|
||||
expect(shouldPausePlacementAfterExit(baseParams)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not pause while still in placement mode", () => {
|
||||
expect(
|
||||
shouldPausePlacementAfterExit({
|
||||
...baseParams,
|
||||
isInPlacementMode: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not pause when placement mode was never active", () => {
|
||||
expect(
|
||||
shouldPausePlacementAfterExit({
|
||||
...baseParams,
|
||||
wasInPlacementMode: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not pause when 'place multiple' is enabled", () => {
|
||||
expect(
|
||||
shouldPausePlacementAfterExit({
|
||||
...baseParams,
|
||||
placeMultiple: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not pause once signatures have been applied", () => {
|
||||
expect(
|
||||
shouldPausePlacementAfterExit({
|
||||
...baseParams,
|
||||
signaturesApplied: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not pause when placement is disabled", () => {
|
||||
expect(
|
||||
shouldPausePlacementAfterExit({
|
||||
...baseParams,
|
||||
placementEnabled: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not re-pause when already paused", () => {
|
||||
expect(
|
||||
shouldPausePlacementAfterExit({
|
||||
...baseParams,
|
||||
alreadyPaused: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
interface PausePlacementParams {
|
||||
/** Whether placement mode was active on the previous render. */
|
||||
wasInPlacementMode: boolean;
|
||||
/** Whether placement mode is active now. */
|
||||
isInPlacementMode: boolean;
|
||||
/** User opted into dropping several stamps in a row. */
|
||||
placeMultiple: boolean;
|
||||
/** True once placed signatures have been flattened/applied to the document. */
|
||||
signaturesApplied: boolean;
|
||||
/** Whether a signature is configured and placement is allowed. */
|
||||
placementEnabled: boolean;
|
||||
/** Whether placement is already manually paused. */
|
||||
alreadyPaused: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether placement should be paused after the viewer auto-exits placement mode.
|
||||
*
|
||||
* The SignatureAPIBridge drops out of placement mode after a single stamp is
|
||||
* placed (unless "place multiple" is on). Without pausing, SignSettings' own
|
||||
* auto-activate effect would immediately re-enter placement mode, so single
|
||||
* placement would never stick and the "place multiple" checkbox would appear to
|
||||
* do nothing. We only react to the placement-mode exit (the bridge already
|
||||
* limits auto-exit to stamp placements, never mid-signature ink strokes).
|
||||
*/
|
||||
export function shouldPausePlacementAfterExit(
|
||||
params: PausePlacementParams,
|
||||
): boolean {
|
||||
const justExitedPlacement =
|
||||
params.wasInPlacementMode && !params.isInPlacementMode;
|
||||
|
||||
return (
|
||||
justExitedPlacement &&
|
||||
!params.placeMultiple &&
|
||||
!params.signaturesApplied &&
|
||||
params.placementEnabled &&
|
||||
!params.alreadyPaused
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, waitFor, act } from "@testing-library/react";
|
||||
import { PdfAnnotationSubtype } from "@embedpdf/models";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
annotationApi: null as unknown,
|
||||
signature: {} as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
vi.mock("@embedpdf/plugin-annotation/react", () => ({
|
||||
useAnnotationCapability: () => ({ provides: mocks.annotationApi }),
|
||||
}));
|
||||
vi.mock("@app/contexts/SignatureContext", () => ({
|
||||
useSignature: () => mocks.signature,
|
||||
}));
|
||||
vi.mock("@app/contexts/ViewerContext", () => ({
|
||||
useViewer: () => ({
|
||||
getZoomState: () => ({ currentZoom: 1 }),
|
||||
registerImmediateZoomUpdate: () => () => {},
|
||||
}),
|
||||
}));
|
||||
vi.mock("@app/components/viewer/hooks/useDocumentReady", () => ({
|
||||
useDocumentReady: () => true,
|
||||
}));
|
||||
|
||||
import { SignatureAPIBridge } from "@app/components/viewer/SignatureAPIBridge";
|
||||
|
||||
const SIGNATURE_DATA = "data:image/png;base64,iVBORw0KGgo=";
|
||||
|
||||
type AnnotationEvent = {
|
||||
type: string;
|
||||
annotation: { id: string; type: number };
|
||||
ctx?: unknown;
|
||||
};
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
/**
|
||||
* Stand-in for @embedpdf/plugin-annotation's capability. `placeStamp` mirrors
|
||||
* the real onCommit ordering (dist/index.js): the create event is emitted
|
||||
* synchronously, and only then does deactivateToolAfterCreate disarm the tool.
|
||||
*/
|
||||
function makeAnnotationApi() {
|
||||
const listeners = new Set<(event: AnnotationEvent) => void>();
|
||||
let activeTool: { id: string } | null = null;
|
||||
let placedCount = 0;
|
||||
|
||||
return {
|
||||
setActiveTool: vi.fn((id: string | null) => {
|
||||
activeTool = id ? { id } : null;
|
||||
}),
|
||||
getActiveTool: vi.fn(() => activeTool),
|
||||
setToolDefaults: vi.fn(),
|
||||
onAnnotationEvent: vi.fn((cb: (event: AnnotationEvent) => void) => {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
}),
|
||||
getSelectedAnnotation: vi.fn(() => null),
|
||||
deleteAnnotation: vi.fn(),
|
||||
|
||||
/** A pointer placement. Throws if the tool is disarmed, as the real one is a no-op then. */
|
||||
placeStamp(id: string) {
|
||||
if (activeTool?.id !== "stamp") {
|
||||
throw new Error(
|
||||
`Cannot place "${id}": stamp tool not armed (active: ${activeTool?.id ?? "none"})`,
|
||||
);
|
||||
}
|
||||
placedCount += 1;
|
||||
listeners.forEach((cb) =>
|
||||
cb({
|
||||
type: "create",
|
||||
annotation: { id, type: PdfAnnotationSubtype.STAMP },
|
||||
ctx: { pointer: true },
|
||||
}),
|
||||
);
|
||||
activeTool = null;
|
||||
},
|
||||
|
||||
/** A paste / undo-redo restore: a create event with no pointer context. */
|
||||
restoreStamp(id: string) {
|
||||
listeners.forEach((cb) =>
|
||||
cb({
|
||||
type: "create",
|
||||
annotation: { id, type: PdfAnnotationSubtype.STAMP },
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
activeToolId: () => activeTool?.id ?? null,
|
||||
placedCount: () => placedCount,
|
||||
};
|
||||
}
|
||||
|
||||
type FakeAnnotationApi = ReturnType<typeof makeAnnotationApi>;
|
||||
|
||||
function setup(placeMultiple: boolean) {
|
||||
const api = makeAnnotationApi();
|
||||
mocks.annotationApi = api;
|
||||
const setPlacementMode = vi.fn();
|
||||
mocks.signature = {
|
||||
signatureConfig: {
|
||||
signatureType: "image",
|
||||
signatureData: SIGNATURE_DATA,
|
||||
reason: "Test",
|
||||
},
|
||||
storeImageData: vi.fn(),
|
||||
isPlacementMode: true,
|
||||
placementPreviewSize: { width: 100, height: 50 },
|
||||
setSignaturesApplied: vi.fn(),
|
||||
placeMultiple,
|
||||
autoExitAfterStampPlacement: true,
|
||||
setPlacementMode,
|
||||
};
|
||||
const view = render(<SignatureAPIBridge />);
|
||||
return { api, setPlacementMode, view };
|
||||
}
|
||||
|
||||
const expectArmed = (api: FakeAnnotationApi) =>
|
||||
waitFor(() => expect(api.activeToolId()).toBe("stamp"));
|
||||
|
||||
describe("SignatureAPIBridge stamp re-arming", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("lets the user place several stamps in a row when placeMultiple is on", async () => {
|
||||
const { api, setPlacementMode } = setup(true);
|
||||
await expectArmed(api);
|
||||
|
||||
// First placement: the plugin disarms the tool the moment it commits.
|
||||
act(() => api.placeStamp("stamp-1"));
|
||||
expect(api.activeToolId()).toBeNull();
|
||||
|
||||
// The bridge must re-arm it without the user re-selecting the tool.
|
||||
await expectArmed(api);
|
||||
act(() => api.placeStamp("stamp-2"));
|
||||
await expectArmed(api);
|
||||
act(() => api.placeStamp("stamp-3"));
|
||||
await expectArmed(api);
|
||||
|
||||
expect(api.placedCount()).toBe(3);
|
||||
expect(setPlacementMode).not.toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("leaves the tool disarmed and exits placement mode when placeMultiple is off", async () => {
|
||||
const { api, setPlacementMode } = setup(false);
|
||||
await expectArmed(api);
|
||||
|
||||
act(() => api.placeStamp("stamp-1"));
|
||||
await settle();
|
||||
|
||||
expect(setPlacementMode).toHaveBeenCalledWith(false);
|
||||
expect(api.activeToolId()).toBeNull();
|
||||
expect(api.placedCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("does not re-arm on a programmatic create (paste, undo/redo restore)", async () => {
|
||||
const { api } = setup(true);
|
||||
await expectArmed(api);
|
||||
const armCount = api.setToolDefaults.mock.calls.length;
|
||||
|
||||
act(() => api.restoreStamp("pasted-1"));
|
||||
await settle();
|
||||
|
||||
expect(api.setToolDefaults.mock.calls.length).toBe(armCount);
|
||||
});
|
||||
|
||||
it("clears pending re-arm timers on unmount", async () => {
|
||||
const { api, view } = setup(true);
|
||||
await expectArmed(api);
|
||||
|
||||
act(() => api.placeStamp("stamp-1"));
|
||||
act(() => view.unmount());
|
||||
await settle();
|
||||
|
||||
expect(api.activeToolId()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,10 @@ import type {
|
||||
import type { SignParameters } from "@app/hooks/tools/sign/useSignParameters";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useDocumentReady } from "@app/components/viewer/hooks/useDocumentReady";
|
||||
import {
|
||||
shouldAutoExitPlacement,
|
||||
shouldRearmPlacement,
|
||||
} from "@app/components/viewer/signaturePlacement";
|
||||
|
||||
/**
|
||||
* Connects the PDF signature (stamp/ink) tools to the shared ViewerContext and SignatureContext.
|
||||
@@ -185,7 +189,21 @@ export const SignatureAPIBridge = forwardRef<
|
||||
isPlacementMode,
|
||||
placementPreviewSize,
|
||||
setSignaturesApplied,
|
||||
placeMultiple,
|
||||
autoExitAfterStampPlacement,
|
||||
setPlacementMode,
|
||||
} = useSignature();
|
||||
// Track the latest toggles in refs so the long-lived onAnnotationEvent
|
||||
// subscription always reads current values without re-subscribing on every
|
||||
// change (which would race with mid-flight create events).
|
||||
const placeMultipleRef = useRef(placeMultiple);
|
||||
useEffect(() => {
|
||||
placeMultipleRef.current = placeMultiple;
|
||||
}, [placeMultiple]);
|
||||
const autoExitRef = useRef(autoExitAfterStampPlacement);
|
||||
useEffect(() => {
|
||||
autoExitRef.current = autoExitAfterStampPlacement;
|
||||
}, [autoExitAfterStampPlacement]);
|
||||
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
|
||||
const documentReady = useDocumentReady();
|
||||
const [currentZoom, setCurrentZoom] = useState(
|
||||
@@ -300,6 +318,13 @@ export const SignatureAPIBridge = forwardRef<
|
||||
cssToPdfSize,
|
||||
]);
|
||||
|
||||
// Mirrored so the long-lived create subscription is not rebuilt per config change.
|
||||
const configureStampDefaultsRef = useRef(configureStampDefaults);
|
||||
useEffect(() => {
|
||||
configureStampDefaultsRef.current = configureStampDefaults;
|
||||
}, [configureStampDefaults]);
|
||||
const rearmTimersRef = useRef(new Set<number>());
|
||||
|
||||
// Enable keyboard deletion of selected annotations
|
||||
useEffect(() => {
|
||||
// Always enable delete key when we have annotation API and are in sign mode
|
||||
@@ -555,6 +580,39 @@ export const SignatureAPIBridge = forwardRef<
|
||||
// Mark signatures as not applied when a new signature is placed
|
||||
if (event.type === "create") {
|
||||
setSignaturesApplied(false);
|
||||
|
||||
// Only pointer placements carry a create context; paste and undo/redo
|
||||
// restores go through createAnnotation without one.
|
||||
const userPlaced = Boolean(event.ctx);
|
||||
|
||||
if (
|
||||
shouldAutoExitPlacement({
|
||||
annotation,
|
||||
placeMultiple: placeMultipleRef.current,
|
||||
autoExitEnabled: autoExitRef.current,
|
||||
userPlaced,
|
||||
})
|
||||
) {
|
||||
annotationApi.setActiveTool(null);
|
||||
setPlacementMode(false);
|
||||
} else if (
|
||||
shouldRearmPlacement({
|
||||
annotation,
|
||||
placeMultiple: placeMultipleRef.current,
|
||||
autoExitEnabled: autoExitRef.current,
|
||||
userPlaced,
|
||||
})
|
||||
) {
|
||||
// The plugin calls setActiveTool(null) right after this event fires,
|
||||
// so re-arm on the next task rather than inline.
|
||||
const timer = window.setTimeout(() => {
|
||||
rearmTimersRef.current.delete(timer);
|
||||
configureStampDefaultsRef.current().catch((error) => {
|
||||
console.error("Error re-arming signature placement:", error);
|
||||
});
|
||||
}, 0);
|
||||
rearmTimersRef.current.add(timer);
|
||||
}
|
||||
}
|
||||
|
||||
const directData =
|
||||
@@ -573,10 +631,19 @@ export const SignatureAPIBridge = forwardRef<
|
||||
}
|
||||
});
|
||||
|
||||
const rearmTimers = rearmTimersRef.current;
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
rearmTimers.forEach((id) => window.clearTimeout(id));
|
||||
rearmTimers.clear();
|
||||
};
|
||||
}, [annotationApi, storeImageData, setSignaturesApplied, documentReady]);
|
||||
}, [
|
||||
annotationApi,
|
||||
storeImageData,
|
||||
setSignaturesApplied,
|
||||
setPlacementMode,
|
||||
documentReady,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlacementMode || !documentReady) {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PdfAnnotationSubtype } from "@embedpdf/models";
|
||||
import {
|
||||
shouldAutoExitPlacement,
|
||||
shouldRearmPlacement,
|
||||
type AutoExitPlacementParams,
|
||||
} from "@app/components/viewer/signaturePlacement";
|
||||
|
||||
const params = (
|
||||
overrides: Partial<AutoExitPlacementParams> = {},
|
||||
): AutoExitPlacementParams => ({
|
||||
annotation: { type: PdfAnnotationSubtype.STAMP },
|
||||
placeMultiple: false,
|
||||
autoExitEnabled: true,
|
||||
userPlaced: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("shouldAutoExitPlacement", () => {
|
||||
it("returns true for a user-placed stamp when placeMultiple is false", () => {
|
||||
expect(shouldAutoExitPlacement(params())).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a stamp annotation when placeMultiple is true", () => {
|
||||
expect(shouldAutoExitPlacement(params({ placeMultiple: true }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when the mounted tool has not opted in", () => {
|
||||
expect(shouldAutoExitPlacement(params({ autoExitEnabled: false }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for programmatic creates (paste, undo/redo restore)", () => {
|
||||
expect(shouldAutoExitPlacement(params({ userPlaced: false }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an ink-stroke annotation regardless of placeMultiple", () => {
|
||||
expect(
|
||||
shouldAutoExitPlacement(
|
||||
params({ annotation: { type: PdfAnnotationSubtype.INK } }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAutoExitPlacement(
|
||||
params({
|
||||
annotation: { type: PdfAnnotationSubtype.INK },
|
||||
placeMultiple: true,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to annotation.object.type when annotation.type is missing", () => {
|
||||
expect(
|
||||
shouldAutoExitPlacement(
|
||||
params({
|
||||
annotation: { object: { type: PdfAnnotationSubtype.STAMP } },
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when neither annotation.type nor object.type is present", () => {
|
||||
expect(shouldAutoExitPlacement(params({ annotation: {} }))).toBe(false);
|
||||
expect(shouldAutoExitPlacement(params({ annotation: null }))).toBe(false);
|
||||
expect(shouldAutoExitPlacement(params({ annotation: undefined }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for FREETEXT, HIGHLIGHT, and other non-stamp types", () => {
|
||||
expect(
|
||||
shouldAutoExitPlacement(
|
||||
params({ annotation: { type: PdfAnnotationSubtype.FREETEXT } }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAutoExitPlacement(
|
||||
params({ annotation: { type: PdfAnnotationSubtype.HIGHLIGHT } }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRearmPlacement", () => {
|
||||
it("returns true for a user-placed stamp when placeMultiple is on", () => {
|
||||
expect(shouldRearmPlacement(params({ placeMultiple: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when placeMultiple is off (auto-exit handles that case)", () => {
|
||||
expect(shouldRearmPlacement(params())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the mounted tool has not opted in", () => {
|
||||
expect(
|
||||
shouldRearmPlacement(
|
||||
params({ placeMultiple: true, autoExitEnabled: false }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for programmatic creates (paste, undo/redo restore)", () => {
|
||||
expect(
|
||||
shouldRearmPlacement(params({ placeMultiple: true, userPlaced: false })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for ink strokes, so multi-stroke signatures still work", () => {
|
||||
expect(
|
||||
shouldRearmPlacement(
|
||||
params({
|
||||
placeMultiple: true,
|
||||
annotation: { type: PdfAnnotationSubtype.INK },
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for FREETEXT and other non-stamp types", () => {
|
||||
expect(
|
||||
shouldRearmPlacement(
|
||||
params({
|
||||
placeMultiple: true,
|
||||
annotation: { type: PdfAnnotationSubtype.FREETEXT },
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to annotation.object.type when annotation.type is missing", () => {
|
||||
expect(
|
||||
shouldRearmPlacement(
|
||||
params({
|
||||
placeMultiple: true,
|
||||
annotation: { object: { type: PdfAnnotationSubtype.STAMP } },
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when neither annotation.type nor object.type is present", () => {
|
||||
expect(
|
||||
shouldRearmPlacement(params({ placeMultiple: true, annotation: {} })),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRearmPlacement(params({ placeMultiple: true, annotation: null })),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRearmPlacement(
|
||||
params({ placeMultiple: true, annotation: undefined }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("never agrees with shouldAutoExitPlacement for the same input", () => {
|
||||
for (const placeMultiple of [true, false]) {
|
||||
const input = params({ placeMultiple });
|
||||
expect(
|
||||
shouldAutoExitPlacement(input) && shouldRearmPlacement(input),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { PdfAnnotationSubtype } from "@embedpdf/models";
|
||||
|
||||
interface AutoExitCandidate {
|
||||
type?: unknown;
|
||||
object?: { type?: unknown };
|
||||
}
|
||||
|
||||
export interface AutoExitPlacementParams {
|
||||
/** The annotation carried by the `create` event. */
|
||||
annotation: AutoExitCandidate | null | undefined;
|
||||
/** User opted into dropping several stamps in a row. */
|
||||
placeMultiple: boolean;
|
||||
/** Only the stamp/sign tools opt in; Annotate shares the "stamp" tool id. */
|
||||
autoExitEnabled: boolean;
|
||||
/** True when the user placed the stamp with the pointer, not a paste/undo. */
|
||||
userPlaced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether placement mode should auto-exit after a `create` annotation event.
|
||||
*
|
||||
* Only stamp annotations placed by the user while a stamp/sign tool is mounted
|
||||
* trigger auto-exit. Ink strokes also raise `create` events, but a multi-stroke
|
||||
* signature would break if we deactivated the tool after the first stroke.
|
||||
* Programmatic creates (Ctrl+V paste, undo/redo restore) must never disarm the
|
||||
* tool the user has armed.
|
||||
*/
|
||||
export function shouldAutoExitPlacement(
|
||||
params: AutoExitPlacementParams,
|
||||
): boolean {
|
||||
if (!params.autoExitEnabled || !params.userPlaced || params.placeMultiple) {
|
||||
return false;
|
||||
}
|
||||
const type =
|
||||
params.annotation?.type ?? params.annotation?.object?.type ?? null;
|
||||
return type === PdfAnnotationSubtype.STAMP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the stamp tool must be re-armed after a user placement.
|
||||
*
|
||||
* The shared "stamp" tool carries `deactivateToolAfterCreate`, so the plugin
|
||||
* disarms it after every placement. Without an explicit re-arm the panel keeps
|
||||
* offering "Pause placement" while clicking the page does nothing. The same
|
||||
* exclusions as auto-exit apply: only user-placed stamps count.
|
||||
*/
|
||||
export function shouldRearmPlacement(params: AutoExitPlacementParams): boolean {
|
||||
if (!params.autoExitEnabled || !params.userPlaced || !params.placeMultiple) {
|
||||
return false;
|
||||
}
|
||||
const type =
|
||||
params.annotation?.type ?? params.annotation?.object?.type ?? null;
|
||||
return type === PdfAnnotationSubtype.STAMP;
|
||||
}
|
||||
@@ -23,6 +23,13 @@ interface SignatureState {
|
||||
signaturesApplied: boolean;
|
||||
// Size (in screen units) we want newly placed signatures to use
|
||||
placementPreviewSize: { width: number; height: number } | null;
|
||||
// When false (default), placement mode auto-exits after a single stamp is
|
||||
// dropped so users don't accidentally place duplicates. When true, the user
|
||||
// opted into staying in placement mode and can drop multiple stamps in a row.
|
||||
placeMultiple: boolean;
|
||||
// Opt-in from the stamp/sign tools only. Annotate shares the "stamp" tool id
|
||||
// and must not be kicked out of its own placement flow.
|
||||
autoExitAfterStampPlacement: boolean;
|
||||
}
|
||||
|
||||
// Signature actions interface
|
||||
@@ -42,6 +49,8 @@ interface SignatureActions {
|
||||
setPlacementPreviewSize: (
|
||||
size: { width: number; height: number } | null,
|
||||
) => void;
|
||||
setPlaceMultiple: (enabled: boolean) => void;
|
||||
setAutoExitAfterStampPlacement: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
// Combined context interface
|
||||
@@ -62,6 +71,8 @@ const initialState: SignatureState = {
|
||||
isPlacementMode: false,
|
||||
signaturesApplied: true, // Start as true (no signatures placed yet)
|
||||
placementPreviewSize: null,
|
||||
placeMultiple: false,
|
||||
autoExitAfterStampPlacement: false,
|
||||
};
|
||||
|
||||
// Provider component
|
||||
@@ -174,6 +185,20 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const setPlaceMultiple = useCallback((enabled: boolean) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
placeMultiple: enabled,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setAutoExitAfterStampPlacement = useCallback((enabled: boolean) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
autoExitAfterStampPlacement: enabled,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// No auto-activation - all modes use manual buttons
|
||||
|
||||
const contextValue: SignatureContextValue = {
|
||||
@@ -194,6 +219,8 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({
|
||||
getImageData,
|
||||
setSignaturesApplied,
|
||||
setPlacementPreviewSize,
|
||||
setPlaceMultiple,
|
||||
setAutoExitAfterStampPlacement,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import { uploadFiles, dismissTourTooltip } from "@app/tests/helpers/ui-helpers";
|
||||
import type { Page } from "@playwright/test";
|
||||
import path from "path";
|
||||
|
||||
const SAMPLE_PDF = path.join(
|
||||
import.meta.dirname,
|
||||
"../test-fixtures/sample.pdf",
|
||||
);
|
||||
|
||||
const FIRST_PAGE = '[data-page-index="0"]';
|
||||
const pauseButton = (page: Page) =>
|
||||
page.getByRole("button", { name: /pause placement/i });
|
||||
const resumeButton = (page: Page) =>
|
||||
page.getByRole("button", { name: /resume placement/i });
|
||||
|
||||
/**
|
||||
* Reviewer report: on the stamp-style tools the "place multiple" checkbox
|
||||
* "doesn't do anything". Single placement never stuck because, after the
|
||||
* viewer auto-exited placement mode, SignSettings' auto-activate effect
|
||||
* immediately re-entered it. These tests drive a real text-stamp placement and
|
||||
* assert the placement toggle settles into the correct state:
|
||||
* - box off (default): placement exits after one stamp (Resume offered)
|
||||
* - box on: placement stays active across stamps (Pause stays offered)
|
||||
*
|
||||
* Unit coverage of the decision lives in
|
||||
* components/tools/sign/placementMode.test.ts; this spec guards the end-to-end
|
||||
* UX so future refactors don't silently regress it.
|
||||
*/
|
||||
test.describe("AddText place-multiple behaviour", () => {
|
||||
async function enterPlacementMode(page: Page): Promise<void> {
|
||||
await page.goto("/add-text");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
await dismissTourTooltip(page);
|
||||
|
||||
// Wait for the PDF to render before configuring the signature. Placement is
|
||||
// activated on text change and only fires once, so the viewer (and its
|
||||
// SignatureAPIBridge) must be mounted first or the activation is lost.
|
||||
await expect(page.locator(FIRST_PAGE).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Configuring a text signature auto-activates placement mode.
|
||||
await page
|
||||
.getByPlaceholder("Enter the text you want to add")
|
||||
.fill("Reviewer Test");
|
||||
|
||||
// Placement active -> the toggle offers "Pause placement".
|
||||
await expect(pauseButton(page)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
test("single placement (default) exits placement mode", async ({ page }) => {
|
||||
await enterPlacementMode(page);
|
||||
|
||||
await page
|
||||
.locator(FIRST_PAGE)
|
||||
.first()
|
||||
.click({ position: { x: 150, y: 150 } });
|
||||
|
||||
// Wait past the 60ms auto-activate window: with the fix the tool stays
|
||||
// exited; without it placement bounces straight back into "Pause" mode.
|
||||
await page.waitForTimeout(750);
|
||||
|
||||
await expect(resumeButton(page)).toBeVisible();
|
||||
await expect(pauseButton(page)).toBeHidden();
|
||||
});
|
||||
|
||||
test("'place multiple' keeps placement active after a placement", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enterPlacementMode(page);
|
||||
|
||||
await page
|
||||
.getByRole("checkbox", {
|
||||
name: /stay in placement mode after each placement/i,
|
||||
})
|
||||
.check();
|
||||
|
||||
await page
|
||||
.locator(FIRST_PAGE)
|
||||
.first()
|
||||
.click({ position: { x: 150, y: 150 } });
|
||||
await page.waitForTimeout(750);
|
||||
|
||||
// Still placing: Pause stays offered, Resume never appears.
|
||||
await expect(pauseButton(page)).toBeVisible();
|
||||
await expect(resumeButton(page)).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
|
||||
import path from "path";
|
||||
|
||||
const SAMPLE_PDF = path.join(
|
||||
import.meta.dirname,
|
||||
"../test-fixtures/sample.pdf",
|
||||
);
|
||||
|
||||
const TOOLS_WITH_PLACEMENT_TOGGLE: Array<{ name: string; url: string }> = [
|
||||
{ name: "AddImage", url: "/add-image" },
|
||||
{ name: "AddText", url: "/add-text" },
|
||||
{ name: "Sign", url: "/sign" },
|
||||
];
|
||||
|
||||
/**
|
||||
* The "place multiple" checkbox is rendered by SignSettings for the three
|
||||
* stamp-style tools (AddImage / AddText / Sign). Behaviour contract:
|
||||
* - Default unchecked: placement mode auto-exits after one stamp
|
||||
* - Toggling on keeps the user in placement mode across multiple stamps
|
||||
* Helper logic is unit-tested in signaturePlacement.test.ts; this spec
|
||||
* verifies the UI contract end-to-end so future SignSettings refactors do
|
||||
* not silently regress the default UX.
|
||||
*/
|
||||
for (const tool of TOOLS_WITH_PLACEMENT_TOGGLE) {
|
||||
test.describe(`${tool.name} placement toggle`, () => {
|
||||
test("renders the 'place multiple' checkbox unchecked by default", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(tool.url);
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
|
||||
const placeMultiple = page.getByRole("checkbox", {
|
||||
name: /stay in placement mode after each placement/i,
|
||||
});
|
||||
await expect(placeMultiple).toBeVisible({ timeout: 15_000 });
|
||||
await expect(placeMultiple).not.toBeChecked();
|
||||
});
|
||||
|
||||
test("toggling the 'place multiple' checkbox flips its state", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(tool.url);
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
|
||||
const placeMultiple = page.getByRole("checkbox", {
|
||||
name: /stay in placement mode after each placement/i,
|
||||
});
|
||||
await expect(placeMultiple).toBeVisible({ timeout: 15_000 });
|
||||
await placeMultiple.check();
|
||||
await expect(placeMultiple).toBeChecked();
|
||||
await placeMultiple.uncheck();
|
||||
await expect(placeMultiple).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -73,6 +73,9 @@ export const createStampTool = (config: StampToolConfig) => {
|
||||
signatureApiRef,
|
||||
getImageData,
|
||||
setSignaturesApplied,
|
||||
setPlacementMode,
|
||||
setPlaceMultiple,
|
||||
setAutoExitAfterStampPlacement,
|
||||
} = useSignature();
|
||||
const { consumeFiles, selectors } = useFileContext();
|
||||
const {
|
||||
@@ -143,6 +146,25 @@ export const createStampTool = (config: StampToolConfig) => {
|
||||
setSignatureConfig(base.params.parameters);
|
||||
}, [base.params.parameters, setSignatureConfig]);
|
||||
|
||||
// Opt this tool into single-placement. Annotate shares the "stamp" tool id
|
||||
// and must keep its own placement flow.
|
||||
useEffect(() => {
|
||||
setAutoExitAfterStampPlacement(true);
|
||||
return () => setAutoExitAfterStampPlacement(false);
|
||||
}, [setAutoExitAfterStampPlacement]);
|
||||
|
||||
// When the tool unmounts (user navigates to a different tool), leave
|
||||
// placement mode and clear any active stamp/ink tool. Otherwise the
|
||||
// placement state and the "place signature" cursor leak into whatever the
|
||||
// user opens next.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
handleDeactivateSignature();
|
||||
setPlacementMode(false);
|
||||
setPlaceMultiple(false);
|
||||
};
|
||||
}, [handleDeactivateSignature, setPlacementMode, setPlaceMultiple]);
|
||||
|
||||
const handleSaveToSystem = useCallback(async () => {
|
||||
try {
|
||||
unregisterUnsavedChangesChecker();
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
Alert,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Group,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
PLACEMENT_ACTIVATION_DELAY,
|
||||
FILE_SWITCH_ACTIVATION_DELAY,
|
||||
} from "@app/constants/signConstants";
|
||||
import { shouldPausePlacementAfterExit } from "@app/components/tools/sign/placementMode";
|
||||
|
||||
// Import the new reusable components
|
||||
import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas";
|
||||
@@ -95,7 +97,13 @@ const SignSettings = ({
|
||||
defaultSignatureSource,
|
||||
}: SignSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { isPlacementMode, signaturesApplied, historyApiRef } = useSignature();
|
||||
const {
|
||||
isPlacementMode,
|
||||
signaturesApplied,
|
||||
historyApiRef,
|
||||
placeMultiple,
|
||||
setPlaceMultiple,
|
||||
} = useSignature();
|
||||
const { activeFileIndex } = useViewer();
|
||||
const [historyAvailability, setHistoryAvailability] = useState({
|
||||
canUndo: false,
|
||||
@@ -937,6 +945,34 @@ const SignSettings = ({
|
||||
onParameterChange,
|
||||
]);
|
||||
|
||||
// After a single placement (when "place multiple" is off) the
|
||||
// SignatureAPIBridge drops out of placement mode. Without pausing here, the
|
||||
// auto-activate effect below would immediately pull the user back into
|
||||
// placement mode - so single placement would never stick and the "place
|
||||
// multiple" checkbox would appear to do nothing.
|
||||
const wasPlacementModeRef = useRef(isPlacementMode);
|
||||
useEffect(() => {
|
||||
const shouldPause = shouldPausePlacementAfterExit({
|
||||
wasInPlacementMode: wasPlacementModeRef.current,
|
||||
isInPlacementMode: isPlacementMode,
|
||||
placeMultiple,
|
||||
signaturesApplied,
|
||||
placementEnabled: shouldEnablePlacement,
|
||||
alreadyPaused: isPlacementManuallyPaused,
|
||||
});
|
||||
wasPlacementModeRef.current = isPlacementMode;
|
||||
|
||||
if (shouldPause) {
|
||||
setPlacementManuallyPaused(true);
|
||||
}
|
||||
}, [
|
||||
isPlacementMode,
|
||||
placeMultiple,
|
||||
signaturesApplied,
|
||||
shouldEnablePlacement,
|
||||
isPlacementManuallyPaused,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldEnablePlacement) {
|
||||
if (isPlacementMode) {
|
||||
@@ -1344,6 +1380,20 @@ const SignSettings = ({
|
||||
<Box style={{ marginLeft: "auto" }}>{placementToggleControl}</Box>
|
||||
</Group>
|
||||
|
||||
<Checkbox
|
||||
checked={placeMultiple}
|
||||
onChange={(event) => setPlaceMultiple(event.currentTarget.checked)}
|
||||
label={translate(
|
||||
"mode.placeMultiple",
|
||||
"Stay in placement mode after each placement",
|
||||
)}
|
||||
description={translate(
|
||||
"mode.placeMultipleDesc",
|
||||
"Place several stamps in a row instead of exiting after the first.",
|
||||
)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Alert color={placementAlert.color} title={placementAlert.title}>
|
||||
<Text size="sm">{placementAlert.message}</Text>
|
||||
</Alert>
|
||||
|
||||
Reference in New Issue
Block a user