@prosopo/procaptcha-puzzle 2.14.7 → 2.16.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.
- package/.turbo/turbo-build$colon$cjs.log +6 -9
- package/.turbo/turbo-build$colon$tsc.log +19 -22
- package/.turbo/turbo-build.log +7 -13
- package/CHANGELOG.md +65 -0
- package/LICENSE +201 -0
- package/dist/cjs/components/procaptchaPuzzle.cjs +2 -17
- package/dist/cjs/components/procaptchaWidget.cjs +22 -19
- package/dist/cjs/components/puzzleCanvas.cjs +35 -5
- package/dist/cjs/services/Manager.cjs +10 -44
- package/dist/components/procaptchaPuzzle.d.ts +3 -3
- package/dist/components/procaptchaPuzzle.d.ts.map +1 -1
- package/dist/components/procaptchaPuzzle.js +2 -17
- package/dist/components/procaptchaPuzzle.js.map +1 -1
- package/dist/components/procaptchaWidget.d.ts.map +1 -1
- package/dist/components/procaptchaWidget.js +21 -18
- package/dist/components/procaptchaWidget.js.map +1 -1
- package/dist/components/puzzleCanvas.d.ts +4 -2
- package/dist/components/puzzleCanvas.d.ts.map +1 -1
- package/dist/components/puzzleCanvas.js +36 -6
- package/dist/components/puzzleCanvas.js.map +1 -1
- package/dist/services/Manager.d.ts.map +1 -1
- package/dist/services/Manager.js +11 -45
- package/dist/services/Manager.js.map +1 -1
- package/dist/tests/procaptchaWidget.unit.test.js +91 -0
- package/dist/tests/procaptchaWidget.unit.test.js.map +1 -1
- package/dist/tests/puzzleCanvas.unit.test.js +69 -0
- package/dist/tests/puzzleCanvas.unit.test.js.map +1 -1
- package/package.json +72 -71
- package/src/components/procaptchaPuzzle.ts +0 -54
- package/src/components/procaptchaWidget.ts +0 -429
- package/src/components/puzzleCanvas.ts +0 -658
- package/src/index.ts +0 -16
- package/src/services/Manager.ts +0 -529
- package/src/tests/domHarness.ts +0 -122
- package/src/tests/invisibleCanvasVisibility.unit.test.ts +0 -181
- package/src/tests/manager.unit.test.ts +0 -840
- package/src/tests/managerHarness.ts +0 -169
- package/src/tests/procaptchaPuzzle.test-d.ts +0 -212
- package/src/tests/procaptchaPuzzle.unit.test.ts +0 -138
- package/src/tests/procaptchaWidget.unit.test.ts +0 -777
- package/src/tests/puzzleCanvas.unit.test.ts +0 -662
- package/tsconfig.cjs.json +0 -43
- package/tsconfig.json +0 -44
- package/tsconfig.tsbuildinfo +0 -1
- 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";
|