Compare commits

...
Author SHA1 Message Date
Anthony Stirling e25f180852 Re-arm the stamp tool after each placement when place multiple is on 2026-08-30 10:45:16 +01:00
Anthony Stirling 1b9e208541 Merge remote-tracking branch 'origin/main' into default-single-placement-for-add-tools 2026-08-30 10:08:21 +01:00
Anthony Stirling cb3c235c91 Merge remote-tracking branch 'origin/main' into sweep/pr6247 2026-08-26 08:12:48 +01:00
Anthony Stirling cfa4df234f Merge remote-tracking branch 'origin/main' into sweep/pr6247 2026-08-26 07:13:49 +01:00
Anthony Stirling 32d483ffe2 gate placement auto-exit to the stamp tools and user placements 2026-08-13 10:10:17 +01:00
Anthony Stirling 2a35b49521 Merge remote-tracking branch 'origin/main' into wf-single-placement 2026-08-13 09:51:48 +01:00
Anthony Stirling 16045dc5bf Merge remote-tracking branch 'origin/main' into default-single-placement-for-add-tools
# Conflicts:
#	frontend/editor/src/core/components/tools/sign/SignSettings.tsx
2026-08-12 11:24:57 +01:00
Frooodle da623a5e64 Merge branch 'main' into default-single-placement-for-add-tools 2026-08-05 09:53:10 +01:00
Frooodle 1bf6d55112 Merge branch 'main' into default-single-placement-for-add-tools 2026-08-02 00:19:56 +01:00
Frooodle 0632a397f2 Merge branch 'main' into default-single-placement-for-add-tools 2026-07-30 11:54:24 +01:00
Frooodle ab010a4f97 Merge branch 'main' into default-single-placement-for-add-tools 2026-07-29 16:11:33 +01:00
Frooodle 4cec23fe9f Merge branch 'main' into default-single-placement-for-add-tools 2026-07-29 08:48:19 +01:00
Frooodle 4179a40544 Merge branch 'main' into default-single-placement-for-add-tools 2026-07-24 11:32:42 +01:00
Frooodle 9a8c67086e Merge branch 'main' into default-single-placement-for-add-tools 2026-07-13 09:34:50 +01:00
Frooodle bc87093588 format specs 2026-07-10 11:50:16 +01:00
Frooodle a8bd5e61f3 fix e2e dirname 2026-07-10 11:40:29 +01:00
Frooodle f981986681 merge main 2026-07-10 10:46:43 +01:00
Frooodle 982991689e merge main 2026-07-06 18:50:40 +01:00
Frooodle baa00ba186 merge main 2026-07-02 09:41:31 +01:00
Frooodle 0456434040 merge main 2026-07-01 08:47:07 +01:00
Frooodle 285a96c342 merge main 2026-06-30 08:30:06 +01:00
Frooodle 08c326d745 merge main 2026-06-29 11:21:13 +01:00
Frooodle 4100e63afe merge main 2026-06-27 08:23:12 +01:00
Frooodle 6a27923af1 merge main 2026-06-25 14:01:14 +01:00
Frooodle 8549176f4a merge main 2026-06-25 09:52:37 +01:00
Frooodle 1f511635cf merge main 2026-06-24 10:41:13 +01:00
Frooodle 058e7bd509 merge main 2026-06-23 15:12:11 +01:00
Anthony Stirling 1ed61c7d9d Merge remote-tracking branch 'origin/main' into default-single-placement-for-add-tools
# Conflicts:
#	frontend/editor/public/locales/en-GB/translation.toml
2026-06-08 15:42:36 +01:00
Anthony Stirling 3f5717b5ad Add Playwright spec covering single vs multiple placement behaviour 2026-05-28 12:04:44 +01:00
Anthony Stirling 3b9113b9a6 Fix place-multiple toggle and reset placement state on tool exit 2026-05-28 11:42:41 +01:00
Anthony Stirling 6dc446a39e Add Playwright spec for place-multiple checkbox 2026-05-26 19:49:09 +01:00
Anthony Stirling c24370c28a Merge remote-tracking branch 'origin/main' into work-6247
# Conflicts:
#	frontend/editor/src/core/components/viewer/signaturePlacement.test.ts
#	frontend/editor/src/core/components/viewer/signaturePlacement.ts
2026-05-26 19:14:51 +01:00
Anthony Stirling 2a82c9ae19 Add unit-tested helper for placement auto-exit decision 2026-05-26 19:08:36 +01:00
Anthony Stirling b70c7b41e3 default single placement for add image, signature and text tools 2026-04-28 11:03:10 +01:00
13 changed files with 881 additions and 3 deletions
@@ -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>