@prosopo/procaptcha-puzzle 2.14.7 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +4 -7
  2. package/.turbo/turbo-build$colon$tsc.log +19 -22
  3. package/.turbo/turbo-build.log +5 -11
  4. package/CHANGELOG.md +30 -0
  5. package/LICENSE +201 -0
  6. package/dist/cjs/components/procaptchaWidget.cjs +18 -4
  7. package/dist/cjs/components/puzzleCanvas.cjs +25 -4
  8. package/dist/components/procaptchaWidget.d.ts.map +1 -1
  9. package/dist/components/procaptchaWidget.js +18 -4
  10. package/dist/components/procaptchaWidget.js.map +1 -1
  11. package/dist/components/puzzleCanvas.d.ts +2 -1
  12. package/dist/components/puzzleCanvas.d.ts.map +1 -1
  13. package/dist/components/puzzleCanvas.js +26 -5
  14. package/dist/components/puzzleCanvas.js.map +1 -1
  15. package/dist/tests/procaptchaWidget.unit.test.js +62 -0
  16. package/dist/tests/procaptchaWidget.unit.test.js.map +1 -1
  17. package/dist/tests/puzzleCanvas.unit.test.js +33 -0
  18. package/dist/tests/puzzleCanvas.unit.test.js.map +1 -1
  19. package/package.json +72 -71
  20. package/src/components/procaptchaPuzzle.ts +0 -54
  21. package/src/components/procaptchaWidget.ts +0 -429
  22. package/src/components/puzzleCanvas.ts +0 -658
  23. package/src/index.ts +0 -16
  24. package/src/services/Manager.ts +0 -529
  25. package/src/tests/domHarness.ts +0 -122
  26. package/src/tests/invisibleCanvasVisibility.unit.test.ts +0 -181
  27. package/src/tests/manager.unit.test.ts +0 -840
  28. package/src/tests/managerHarness.ts +0 -169
  29. package/src/tests/procaptchaPuzzle.test-d.ts +0 -212
  30. package/src/tests/procaptchaPuzzle.unit.test.ts +0 -138
  31. package/src/tests/procaptchaWidget.unit.test.ts +0 -777
  32. package/src/tests/puzzleCanvas.unit.test.ts +0 -662
  33. package/tsconfig.cjs.json +0 -43
  34. package/tsconfig.json +0 -44
  35. package/tsconfig.tsbuildinfo +0 -1
  36. package/tsconfig.types.json +0 -9
@@ -1,658 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- import type { TranslationKey, Translator } from "@prosopo/locale";
16
- import {
17
- type ChallengeSurfaceComponent,
18
- type Component,
19
- type StyleMap,
20
- Teardown,
21
- applyAttributes,
22
- applyStyles,
23
- createElement,
24
- isEventTrusted,
25
- mountChallengeSurface,
26
- } from "@prosopo/procaptcha-common";
27
- import type { PlacementType, PuzzleEvent } from "@prosopo/types";
28
- import type { Theme } from "@prosopo/widget-skeleton";
29
-
30
- export interface PuzzleCanvasProps {
31
- originX: number;
32
- originY: number;
33
- /** Background with the notch already cut into it, as a data URI. */
34
- background: string;
35
- /** The draggable piece on transparency, as a data URI. */
36
- piece: string;
37
- /** Piece bounding-box size in px, as rendered by the provider. */
38
- pieceSize: number;
39
- onComplete: (
40
- finalX: number,
41
- finalY: number,
42
- puzzleEvents: PuzzleEvent[],
43
- ) => void;
44
- showRetry: boolean;
45
- submitting: boolean;
46
- theme: Theme;
47
- translator: Translator;
48
- placement?: PlacementType;
49
- anchor?: HTMLElement | null;
50
- onDismiss?: () => void;
51
- }
52
-
53
- const CONTAINER_WIDTH = 300;
54
- const CONTAINER_HEIGHT = 200;
55
-
56
- const PIECE_CSS_CLASS = "prosopo-puzzle-piece";
57
-
58
- // An arrow press moves a tenth of the board's width. The provider accepts a
59
- // solution within 15px of the target, so a 10px lattice always contains a
60
- // winning cell (worst case is half a diagonal, ~7.1px) — a keyboard user can
61
- // land the piece without ever needing the finer step.
62
- const STEP_PX = 10;
63
- const FINE_STEP_PX = 2;
64
-
65
- // Arrow keys repeat far faster than a screen reader speaks. Coalescing to the
66
- // last position after a pause keeps the running commentary from queueing up
67
- // behind the user and reporting somewhere they left several seconds ago.
68
- const ANNOUNCE_DEBOUNCE_MS = 400;
69
-
70
- const VISUALLY_HIDDEN: StyleMap = {
71
- position: "absolute",
72
- width: "1px",
73
- height: "1px",
74
- padding: 0,
75
- margin: "-1px",
76
- overflow: "hidden",
77
- clip: "rect(0, 0, 0, 0)",
78
- clipPath: "inset(50%)",
79
- whiteSpace: "nowrap",
80
- border: 0,
81
- };
82
-
83
- const stylesheet = (focusRingColor: string): string => `
84
- @keyframes prosopo-puzzle-shake {
85
- 0%, 100% { transform: translateX(0); }
86
- 10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
87
- 20%, 40%, 60%, 80% { transform: translateX(4px); }
88
- }
89
- .${PIECE_CSS_CLASS}:focus-visible {
90
- outline: 3px solid ${focusRingColor};
91
- outline-offset: 2px;
92
- }
93
- `;
94
-
95
- // [column, row, scaleX, scaleY] — the centre tile plus the eight mirrored
96
- // copies that surround it.
97
- const KALEIDOSCOPE_TILES: readonly [number, number, number, number][] = [
98
- [0, 0, -1, -1],
99
- [1, 0, 1, -1],
100
- [2, 0, -1, -1],
101
- [0, 1, -1, 1],
102
- [1, 1, 1, 1],
103
- [2, 1, -1, 1],
104
- [0, 2, -1, -1],
105
- [1, 2, 1, -1],
106
- [2, 2, -1, -1],
107
- ];
108
-
109
- const clamp = (value: number, min: number, max: number): number =>
110
- Math.max(min, Math.min(max, value));
111
-
112
- let instanceCount = 0;
113
-
114
- export const mountPuzzleCanvas = (
115
- initialProps: PuzzleCanvasProps,
116
- ): Component<PuzzleCanvasProps> => {
117
- const teardown = new Teardown();
118
- let props = initialProps;
119
-
120
- let posX = props.originX;
121
- let posY = props.originY;
122
- let dragging = false;
123
- let puzzleEvents: PuzzleEvent[] = [];
124
- let dragOffset = { x: 0, y: 0 };
125
- let visible = false;
126
- let shaking = false;
127
- let shakeTimer: ReturnType<typeof setTimeout> | undefined;
128
- let announceTimer: ReturnType<typeof setTimeout> | undefined;
129
- // Set by the first arrow press of a keyboard run, so the run starts from a
130
- // clean trail exactly as a fresh mouse grab does.
131
- let keyboardDragging = false;
132
-
133
- const baseId = `prosopo-puzzle-${instanceCount++}`;
134
- const instructionId = `${baseId}-instruction`;
135
- const keyboardHintId = `${baseId}-keyboard-hint`;
136
-
137
- const t = (key: TranslationKey, options?: Record<string, unknown>): string =>
138
- props.translator.t(key, options);
139
-
140
- const style = createElement("style", {
141
- text: stylesheet(props.theme.palette.primary.main),
142
- });
143
-
144
- // Progress the piece cannot show a screen reader: where it has got to, that
145
- // a solution is being checked, and that a failed go has been replaced by a
146
- // fresh puzzle. An `output` carries the implicit `status` role a live
147
- // region needs.
148
- const announcer = createElement("output", {
149
- style: VISUALLY_HIDDEN,
150
- attributes: { "aria-live": "polite", "aria-atomic": "true" },
151
- });
152
-
153
- const keyboardHint = createElement("div", {
154
- style: VISUALLY_HIDDEN,
155
- attributes: { id: keyboardHintId },
156
- });
157
-
158
- /*
159
- * The background. The notch is cut into these pixels by the provider; the
160
- * widget is never told where it is. The transform/filter below react only
161
- * to piece displacement, so no target information leaks through them.
162
- *
163
- * The background is tiled as a 3×3 mirrored kaleidoscope so the parallax
164
- * translate never reveals a blank margin — the surrounding 8 tiles are the
165
- * same image mirrored on each axis, giving a seamless continuation in
166
- * every direction.
167
- */
168
- const backgroundTiles = KALEIDOSCOPE_TILES.map(([column, row, sx, sy]) =>
169
- createElement("img", {
170
- style: {
171
- position: "absolute",
172
- left: `${column * CONTAINER_WIDTH}px`,
173
- top: `${row * CONTAINER_HEIGHT}px`,
174
- width: `${CONTAINER_WIDTH}px`,
175
- height: `${CONTAINER_HEIGHT}px`,
176
- transform: `scale(${sx}, ${sy})`,
177
- pointerEvents: "none",
178
- userSelect: "none",
179
- },
180
- attributes: { alt: "", draggable: false },
181
- }),
182
- );
183
-
184
- const backgroundLayer = createElement("div", {
185
- style: {
186
- // Position the wrapper so the centre tile lands at (0, 0), i.e.
187
- // exactly where the untranslated background would sit.
188
- left: `-${CONTAINER_WIDTH}px`,
189
- top: `-${CONTAINER_HEIGHT}px`,
190
- position: "absolute",
191
- width: `${CONTAINER_WIDTH * 3}px`,
192
- height: `${CONTAINER_HEIGHT * 3}px`,
193
- pointerEvents: "none",
194
- userSelect: "none",
195
- transformOrigin: "center center",
196
- willChange: "transform, filter",
197
- },
198
- children: backgroundTiles,
199
- });
200
-
201
- /*
202
- * `application` rather than `button`: in the browse mode NVDA and JAWS
203
- * default to, arrow keys move the reading cursor through the page and never
204
- * reach a button's key handler. `application` is the role that hands them
205
- * straight to this element, which is what makes the drag reachable without
206
- * a pointer at all.
207
- *
208
- * The role and class are a stable production selector, which the data-cy
209
- * below is deliberately not. A scripted solver could already find this
210
- * element — it is the only draggable thing on the surface — whereas without
211
- * them a keyboard or screen-reader user cannot find it at all.
212
- */
213
- const piece = createElement("div", {
214
- className: PIECE_CSS_CLASS,
215
- style: {
216
- position: "absolute",
217
- backgroundSize: "100% 100%",
218
- // Without this, a touch on a zoomed-in mobile viewport is claimed by
219
- // the browser as a pan gesture before our touchmove handler ever
220
- // runs, so the page scrolls instead of the piece moving.
221
- touchAction: "none",
222
- },
223
- attributes: {
224
- role: "application",
225
- "aria-describedby": `${instructionId} ${keyboardHintId}`,
226
- },
227
- });
228
-
229
- // Test-only selector: gated on NODE_ENV !== "production" so the bundler
230
- // constant-folds it out of production builds. Cypress builds the bundle
231
- // with NODE_ENV=development (.github/workflows/cypress.yml:110) so the
232
- // selector is present under test.
233
- if ("production" !== process.env.NODE_ENV) {
234
- applyAttributes(piece, { "data-cy": "prosopo-puzzle-piece" });
235
- }
236
-
237
- const area = createElement("div", {
238
- style: {
239
- position: "relative",
240
- width: `${CONTAINER_WIDTH}px`,
241
- height: `${CONTAINER_HEIGHT}px`,
242
- borderRadius: "0 0 20px 20px",
243
- overflow: "hidden",
244
- userSelect: "none",
245
- transition: "opacity 0.2s ease",
246
- },
247
- children: [backgroundLayer, piece],
248
- });
249
-
250
- const instruction = createElement("div", {
251
- attributes: { id: instructionId },
252
- style: {
253
- borderRadius: "20px 20px 0 0",
254
- padding: "12px 20px",
255
- width: `${CONTAINER_WIDTH}px`,
256
- boxSizing: "border-box",
257
- textAlign: "center",
258
- fontSize: "14px",
259
- fontWeight: 500,
260
- transition: "color 0.3s ease, border-color 0.3s ease",
261
- },
262
- });
263
-
264
- const panel = createElement("div", {
265
- style: {
266
- display: "flex",
267
- flexDirection: "column",
268
- alignItems: "center",
269
- gap: "0",
270
- transition: "opacity 0.3s ease, transform 0.3s ease",
271
- },
272
- children: [instruction, area],
273
- });
274
-
275
- const surfaceProps = () => ({
276
- show: true,
277
- placement: props.placement,
278
- anchor: props.anchor,
279
- onDismiss: props.onDismiss,
280
- scrim: visible ? ("dim" as const) : ("none" as const),
281
- dialogLabel: t("WIDGET.PUZZLE.DIALOG_LABEL", {
282
- defaultValue: "Puzzle challenge",
283
- }),
284
- });
285
-
286
- const surface: ChallengeSurfaceComponent = mountChallengeSurface(
287
- surfaceProps(),
288
- );
289
- surface.content.append(style, announcer, keyboardHint, panel);
290
-
291
- const announce = (message: string, delayMs = 0) => {
292
- if (undefined !== announceTimer) {
293
- clearTimeout(announceTimer);
294
- announceTimer = undefined;
295
- }
296
- if (0 === delayMs) {
297
- announcer.textContent = message;
298
- return;
299
- }
300
- announceTimer = setTimeout(() => {
301
- announcer.textContent = message;
302
- }, delayMs);
303
- };
304
-
305
- // Pixel coordinates mean nothing to someone who cannot see the board, and
306
- // the widget is never told where the target is, so proportions are the only
307
- // bearing it can honestly offer.
308
- const describePosition = (x: number, y: number): string =>
309
- t("WIDGET.PUZZLE.POSITION", {
310
- defaultValue: "{{x}} percent across, {{y}} percent down",
311
- x: Math.round((x / CONTAINER_WIDTH) * 100),
312
- y: Math.round((y / CONTAINER_HEIGHT) * 100),
313
- });
314
-
315
- // The piece position is written straight to the element rather than going
316
- // through a render pass: a pointermove would otherwise rebuild the whole
317
- // overlay for a two-pixel move.
318
- const applyPiecePosition = () => {
319
- applyStyles(piece, {
320
- left: `${posX - props.pieceSize / 2}px`,
321
- top: `${posY - props.pieceSize / 2}px`,
322
- });
323
- };
324
-
325
- const applyPieceChrome = () => {
326
- applyStyles(piece, {
327
- width: `${props.pieceSize}px`,
328
- height: `${props.pieceSize}px`,
329
- backgroundImage: `url(${props.piece})`,
330
- cursor: props.submitting ? "default" : dragging ? "grabbing" : "grab",
331
- filter: dragging
332
- ? "drop-shadow(0 4px 10px rgba(0, 0, 0, 0.45))"
333
- : "drop-shadow(0 2px 5px rgba(0, 0, 0, 0.35))",
334
- transition: dragging
335
- ? "none"
336
- : "filter 0.2s ease, left 0.3s ease, top 0.3s ease",
337
- });
338
- };
339
-
340
- /*
341
- * Client-side reaction to drag motion. Purely visual — computed from the
342
- * piece's displacement from its starting position, never from anything that
343
- * could hint at the target (the widget does not know the target). The
344
- * background translates a few pixels *opposite* the piece for a parallax
345
- * feel, and shifts hue/saturation with drag distance for a subtle
346
- * live-canvas effect. On release everything eases back via the CSS
347
- * transition below.
348
- */
349
- const PARALLAX_FACTOR = 0.08;
350
-
351
- const applyBackgroundMotion = () => {
352
- const deltaX = posX - props.originX;
353
- const deltaY = posY - props.originY;
354
- const dragDistance = Math.hypot(deltaX, deltaY);
355
- const translateX = dragging ? -deltaX * PARALLAX_FACTOR : 0;
356
- const translateY = dragging ? -deltaY * PARALLAX_FACTOR : 0;
357
- const scale = dragging ? 1.03 : 1;
358
- // Cap the filter influence so a long drag doesn't kaleidoscope the frame.
359
- const hueShift = dragging ? clamp(deltaX * 0.12, -18, 18) : 0;
360
- const satBoost = dragging ? 1 + Math.min(0.25, dragDistance * 0.0035) : 1;
361
- const brightness = dragging ? 1 - Math.min(0.06, dragDistance * 0.0008) : 1;
362
-
363
- applyStyles(backgroundLayer, {
364
- transform: `translate(${translateX.toFixed(2)}px, ${translateY.toFixed(2)}px) scale(${scale.toFixed(3)})`,
365
- filter: `hue-rotate(${hueShift.toFixed(2)}deg) saturate(${satBoost.toFixed(3)}) brightness(${brightness.toFixed(3)})`,
366
- transition: dragging
367
- ? "none"
368
- : "transform 0.35s cubic-bezier(0.22, 1, 0.36, 1), filter 0.35s ease",
369
- });
370
- };
371
-
372
- const render = () => {
373
- const { theme } = props;
374
- applyStyles(panel, {
375
- opacity: visible ? 1 : 0,
376
- transform: visible ? "scale(1)" : "scale(0.9)",
377
- animation: shaking ? "prosopo-puzzle-shake 0.5s ease" : "none",
378
- });
379
- instruction.textContent = props.showRetry
380
- ? t("WIDGET.PUZZLE.RETRY", { defaultValue: "Not quite — try again" })
381
- : t("WIDGET.PUZZLE.DRAG", {
382
- defaultValue: "Drag the piece to the target",
383
- });
384
- keyboardHint.textContent = t("WIDGET.PUZZLE.KEYBOARD_HINT", {
385
- defaultValue:
386
- "Use the arrow keys to move the piece, holding shift for smaller steps. Press Enter to submit it, Home to put it back at the start, or Escape to cancel.",
387
- });
388
- applyStyles(instruction, {
389
- backgroundColor: theme.palette.surface,
390
- fontFamily: theme.font.fontFamily,
391
- color: props.showRetry
392
- ? theme.palette.error.main
393
- : theme.palette.onSurface,
394
- borderBottom: `2px solid ${
395
- props.showRetry ? theme.palette.error.main : "transparent"
396
- }`,
397
- });
398
- applyStyles(area, {
399
- // Material 3 purple tonal fallback shown before the server-rendered
400
- // background image loads.
401
- background: `linear-gradient(135deg, ${theme.palette.surface} 0%, ${theme.palette.primaryContainer.main} 50%, ${theme.palette.surface} 100%)`,
402
- opacity: props.submitting ? 0.6 : 1,
403
- pointerEvents: props.submitting ? "none" : "auto",
404
- });
405
- for (const tile of backgroundTiles) {
406
- tile.src = props.background;
407
- }
408
- applyAttributes(piece, {
409
- tabindex: props.submitting ? -1 : 0,
410
- "aria-roledescription": t("WIDGET.PUZZLE.PIECE_ROLE", {
411
- defaultValue: "draggable puzzle piece",
412
- }),
413
- "aria-label": t("WIDGET.PUZZLE.PIECE_LABEL", {
414
- defaultValue: "Puzzle piece",
415
- }),
416
- });
417
- applyPiecePosition();
418
- applyPieceChrome();
419
- applyBackgroundMotion();
420
- // Last, so the surface hands focus to a piece that is already focusable
421
- // and already where the challenge put it.
422
- surface.update(surfaceProps());
423
- };
424
-
425
- const containerOffset = (): { x: number; y: number } => {
426
- const rect = area.getBoundingClientRect();
427
- return { x: rect.left, y: rect.top };
428
- };
429
-
430
- const complete = (finalX: number, finalY: number) => {
431
- props.onComplete(finalX, finalY, [...puzzleEvents]);
432
- };
433
-
434
- const handleMove = (clientX: number, clientY: number) => {
435
- if (!dragging) {
436
- return;
437
- }
438
-
439
- const offset = containerOffset();
440
- posX = clamp(clientX - offset.x - dragOffset.x, 0, CONTAINER_WIDTH);
441
- posY = clamp(clientY - offset.y - dragOffset.y, 0, CONTAINER_HEIGHT);
442
- applyPiecePosition();
443
- applyBackgroundMotion();
444
-
445
- puzzleEvents.push({ x: posX, y: posY, t: Date.now() });
446
- };
447
-
448
- const handleEnd = () => {
449
- if (!dragging) {
450
- return;
451
- }
452
- dragging = false;
453
- applyPieceChrome();
454
- applyBackgroundMotion();
455
-
456
- const lastEvent = puzzleEvents[puzzleEvents.length - 1];
457
- // Pressing and releasing without moving is not an answer. Submitting the
458
- // origin spends the challenge on a position that cannot be correct, and
459
- // the provider then rejects the replacement fetch — so one stray tap ends
460
- // the attempt. A touch screen produces that tap constantly: the panel
461
- // opens under the finger that just pressed the checkbox.
462
- if (!lastEvent) {
463
- return;
464
- }
465
- complete(lastEvent.x, lastEvent.y);
466
- };
467
-
468
- const beginDrag = (clientX: number, clientY: number) => {
469
- if (props.submitting) {
470
- return;
471
- }
472
- dragging = true;
473
- keyboardDragging = false;
474
- puzzleEvents = [];
475
- const offset = containerOffset();
476
- dragOffset = { x: clientX - offset.x - posX, y: clientY - offset.y - posY };
477
- applyPieceChrome();
478
- applyBackgroundMotion();
479
- };
480
-
481
- const moveByKeyboard = (deltaX: number, deltaY: number) => {
482
- if (!keyboardDragging) {
483
- keyboardDragging = true;
484
- puzzleEvents = [];
485
- }
486
-
487
- posX = clamp(posX + deltaX, 0, CONTAINER_WIDTH);
488
- posY = clamp(posY + deltaY, 0, CONTAINER_HEIGHT);
489
- applyPiecePosition();
490
-
491
- puzzleEvents.push({ x: posX, y: posY, t: Date.now() });
492
- announce(describePosition(posX, posY), ANNOUNCE_DEBOUNCE_MS);
493
- };
494
-
495
- const keyboardMove = (
496
- key: string,
497
- step: number,
498
- ): [number, number] | undefined => {
499
- switch (key) {
500
- case "ArrowLeft":
501
- return [-step, 0];
502
- case "ArrowRight":
503
- return [step, 0];
504
- case "ArrowUp":
505
- return [0, -step];
506
- case "ArrowDown":
507
- return [0, step];
508
- case "Home":
509
- return [props.originX - posX, props.originY - posY];
510
- default:
511
- return undefined;
512
- }
513
- };
514
-
515
- teardown.addEventListener(piece, "keydown", (event: Event) => {
516
- if (props.submitting || !isEventTrusted(event)) {
517
- return;
518
- }
519
- const keyboardEvent = event as KeyboardEvent;
520
- const step = keyboardEvent.shiftKey ? FINE_STEP_PX : STEP_PX;
521
-
522
- const move = keyboardMove(keyboardEvent.key, step);
523
- if (move) {
524
- keyboardEvent.preventDefault();
525
- moveByKeyboard(move[0], move[1]);
526
- return;
527
- }
528
-
529
- if ("Enter" === keyboardEvent.key || " " === keyboardEvent.key) {
530
- keyboardEvent.preventDefault();
531
- // Ends the run, so the next arrow press opens a clean trail just as
532
- // the next mouse grab would.
533
- keyboardDragging = false;
534
- complete(posX, posY);
535
- }
536
- });
537
-
538
- teardown.addEventListener(piece, "focus", () => {
539
- announce(describePosition(posX, posY));
540
- });
541
-
542
- teardown.addEventListener(piece, "mousedown", (event: Event) => {
543
- const mouseEvent = event as MouseEvent;
544
- beginDrag(mouseEvent.clientX, mouseEvent.clientY);
545
- });
546
- teardown.addEventListener(piece, "touchstart", (event: Event) => {
547
- const touch = (event as TouchEvent).touches[0];
548
- if (touch) {
549
- beginDrag(touch.clientX, touch.clientY);
550
- }
551
- });
552
-
553
- teardown.addEventListener(document, "mousemove", (event: Event) => {
554
- const mouseEvent = event as MouseEvent;
555
- handleMove(mouseEvent.clientX, mouseEvent.clientY);
556
- });
557
- teardown.addEventListener(document, "mouseup", handleEnd);
558
- teardown.addEventListener(document, "touchmove", (event: Event) => {
559
- const touch = (event as TouchEvent).touches[0];
560
- if (touch) {
561
- handleMove(touch.clientX, touch.clientY);
562
- }
563
- });
564
- teardown.addEventListener(document, "touchend", handleEnd);
565
-
566
- render();
567
-
568
- // Entrance animation, fired after the first paint so the transition runs.
569
- const frame = requestAnimationFrame(() => {
570
- visible = true;
571
- render();
572
- });
573
- teardown.add(() => cancelAnimationFrame(frame));
574
-
575
- const startShake = () => {
576
- shaking = true;
577
- render();
578
- if (undefined !== shakeTimer) {
579
- clearTimeout(shakeTimer);
580
- }
581
- shakeTimer = setTimeout(() => {
582
- shaking = false;
583
- render();
584
- }, 500);
585
- };
586
-
587
- teardown.add(() => {
588
- if (undefined !== shakeTimer) {
589
- clearTimeout(shakeTimer);
590
- }
591
- if (undefined !== announceTimer) {
592
- clearTimeout(announceTimer);
593
- }
594
- });
595
-
596
- const announceSubmitting = () => {
597
- announce(
598
- t("WIDGET.PUZZLE.CHECKING", { defaultValue: "Checking your answer" }),
599
- );
600
- };
601
-
602
- const announceRetry = () => {
603
- announce(
604
- t("WIDGET.PUZZLE.RETRY_ANNOUNCEMENT", {
605
- defaultValue:
606
- "Not quite. A new puzzle has loaded and the piece is back at the start.",
607
- }),
608
- );
609
- };
610
-
611
- if (props.submitting) {
612
- announceSubmitting();
613
- } else if (props.showRetry) {
614
- announceRetry();
615
- }
616
-
617
- if (props.showRetry) {
618
- startShake();
619
- }
620
-
621
- return {
622
- update: (nextProps: PuzzleCanvasProps) => {
623
- const previous = props;
624
- props = nextProps;
625
-
626
- // Reset the piece when the challenge data changes (new puzzle on retry).
627
- if (
628
- nextProps.originX !== previous.originX ||
629
- nextProps.originY !== previous.originY
630
- ) {
631
- posX = nextProps.originX;
632
- posY = nextProps.originY;
633
- keyboardDragging = false;
634
- }
635
-
636
- // Announced on the way into each state, not on every render: a live
637
- // region re-reads whatever is written to it, so repeating the same
638
- // sentence would talk over the position updates.
639
- if (nextProps.submitting && !previous.submitting) {
640
- announceSubmitting();
641
- }
642
- if (nextProps.showRetry && !previous.showRetry) {
643
- announceRetry();
644
- }
645
-
646
- if (nextProps.showRetry && !previous.showRetry) {
647
- startShake();
648
- return;
649
- }
650
-
651
- render();
652
- },
653
- destroy: () => {
654
- teardown.run();
655
- surface.destroy();
656
- },
657
- };
658
- };
package/src/index.ts DELETED
@@ -1,16 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
- export * from "./components/procaptchaWidget.js";
15
- export * from "./components/procaptchaPuzzle.js";
16
- export * from "./components/puzzleCanvas.js";