working prototype

This commit is contained in:
2026-02-20 00:49:20 +01:00
parent 39ddf07973
commit 9b226d60da
5 changed files with 810 additions and 35 deletions

303
plugins/jspsych-captcha.js Normal file
View File

@@ -0,0 +1,303 @@
import { ParameterType } from 'jspsych';
const info = {
name: 'captcha',
parameters: {
prompt: {
type: ParameterType.HTML_STRING,
default: '',
},
difficulty: {
type: ParameterType.INT,
default: 3,
},
button_label: {
type: ParameterType.STRING,
default: 'Submit',
},
allow_refresh: {
type: ParameterType.BOOL,
default: true,
},
require_correct: {
type: ParameterType.BOOL,
default: true,
},
error_text: {
type: ParameterType.HTML_STRING,
default: 'That entry did not match, please try again.',
},
},
};
class CaptchaPlugin {
constructor(jsPsych) {
this.jsPsych = jsPsych;
this.characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
}
trial(display_element, trial) {
const start_time = performance.now();
const attempt_counts = { submission_count: 0, refresh_count: 0 };
const difficulty_settings = this.resolve_difficulty(trial.difficulty);
let captcha_text = this.generate_text(difficulty_settings.length);
let last_target_at_submission = captcha_text;
display_element.innerHTML = this.build_markup(
trial.prompt,
trial.button_label,
trial.allow_refresh
);
const canvas_element = display_element.querySelector(
'.jspsych_captcha_canvas'
);
const canvas_context = canvas_element.getContext('2d');
const input_element = display_element.querySelector(
'.jspsych_captcha_input'
);
const button_element = display_element.querySelector(
'.jspsych_captcha_submit'
);
const refresh_button = display_element.querySelector(
'.jspsych_captcha_refresh'
);
const error_element = display_element.querySelector(
'.jspsych_captcha_error'
);
const draw_captcha = () => {
this.draw_captcha(canvas_context, captcha_text, difficulty_settings);
};
draw_captcha();
input_element.focus();
if (refresh_button) {
refresh_button.addEventListener('click', () => {
attempt_counts.refresh_count += 1;
captcha_text = this.generate_text(difficulty_settings.length);
draw_captcha();
error_element.textContent = '';
input_element.value = '';
input_element.focus();
});
}
const length_hint_element = display_element.querySelector(
'.jspsych_captcha_length_hint'
);
const update_button_state = () => {
const current_length = input_element.value.trim().length;
const required_length = captcha_text.length;
button_element.disabled = current_length < required_length;
if (length_hint_element) {
length_hint_element.textContent =
current_length < required_length
? `Type ${required_length - current_length} more character(s) to continue.`
: '';
}
};
const finish_trial = (response_value, is_correct) => {
const trial_data = {
rt: performance.now() - start_time,
response: response_value,
participant_entry: response_value,
participant_entry_length: response_value.length,
captcha_target: last_target_at_submission,
correct: is_correct,
submission_count: attempt_counts.submission_count,
manual_refreshes: attempt_counts.refresh_count,
difficulty_label: difficulty_settings.label,
difficulty_length: difficulty_settings.length,
difficulty_level: difficulty_settings.level,
};
display_element.innerHTML = '';
this.jsPsych.finishTrial(trial_data);
};
const validate_response = () => {
attempt_counts.submission_count += 1;
const response_value = input_element.value.trim().toUpperCase();
last_target_at_submission = captcha_text;
const is_correct = response_value === captcha_text;
if (response_value.length < captcha_text.length) {
error_element.textContent = `Please enter all ${captcha_text.length} characters shown in the captcha.`;
update_button_state();
return;
}
if (is_correct || !trial.require_correct) {
finish_trial(response_value, is_correct);
return;
}
error_element.textContent = trial.error_text;
captcha_text = this.generate_text(difficulty_settings.length);
draw_captcha();
input_element.value = '';
input_element.focus();
};
button_element.addEventListener('click', validate_response);
input_element.addEventListener('keydown', event => {
if (event.key === 'Enter') {
event.preventDefault();
validate_response();
}
});
input_element.addEventListener('input', update_button_state);
update_button_state();
}
build_markup(prompt, button_label, allow_refresh) {
const prompt_html = prompt
? `<div class="jspsych_captcha_prompt">${prompt}</div>`
: '';
const refresh_button = allow_refresh
? '<button type="button" class="jspsych_captcha_refresh" aria-label="Get a new captcha">↻</button>'
: '';
return `
<div class="jspsych_captcha">
${prompt_html}
<div class="jspsych_captcha_canvas_wrapper">
<canvas class="jspsych_captcha_canvas" width="260" height="110" role="img" aria-label="Captcha"></canvas>
${refresh_button}
</div>
<label class="jspsych_captcha_label" aria-label="Enter the characters you see into the textbox below.">
<input type="text" autocomplete="off" autocorrect="off" autocapitalize="characters" spellcheck="false" class="jspsych_captcha_input" />
<span class="jspsych_captcha_length_hint"></span>
</label>
<button type="button" class="jspsych-btn jspsych_captcha_submit">${button_label}</button>
<div class="jspsych_captcha_error" role="status" aria-live="polite"></div>
</div>
`;
}
draw_captcha(canvas_context, captcha_text, settings) {
const { width, height } = canvas_context.canvas;
canvas_context.clearRect(0, 0, width, height);
const gradient = canvas_context.createLinearGradient(0, 0, width, height);
gradient.addColorStop(0, '#f8fafc');
gradient.addColorStop(1, '#e2e8f0');
canvas_context.fillStyle = gradient;
canvas_context.fillRect(0, 0, width, height);
for (let index = 0; index < settings.noise_lines; index++) {
canvas_context.beginPath();
canvas_context.moveTo(Math.random() * width, Math.random() * height);
canvas_context.bezierCurveTo(
Math.random() * width,
Math.random() * height,
Math.random() * width,
Math.random() * height,
Math.random() * width,
Math.random() * height
);
canvas_context.lineWidth = 1 + Math.random() * 2;
canvas_context.strokeStyle = `rgba(30, 41, 59, ${0.15 +
Math.random() * 0.2})`;
canvas_context.stroke();
}
canvas_context.font = `bold ${settings.font_size}px 'Courier New', monospace`;
canvas_context.textBaseline = 'middle';
canvas_context.textAlign = 'center';
const horizontal_step = width / (captcha_text.length + 1);
const center_y = height / 2;
for (let index = 0; index < captcha_text.length; index++) {
const current_character = captcha_text[index];
canvas_context.save();
const jitter_y = this.random_between(-settings.jitter, settings.jitter);
canvas_context.translate(
horizontal_step * (index + 1),
center_y + jitter_y
);
canvas_context.rotate(
this.random_between(-settings.rotation, settings.rotation)
);
canvas_context.fillStyle = '#000000';
canvas_context.fillText(current_character, 0, 0);
canvas_context.restore();
}
const noise_pixels = Math.floor(width * height * settings.speckle_density);
const image_data = canvas_context.getImageData(0, 0, width, height);
for (let index = 0; index < noise_pixels; index++) {
const pixel_offset =
(Math.floor(Math.random() * width) +
Math.floor(Math.random() * height) * width) *
4;
const shade = Math.random() > 0.5 ? 255 : 0;
image_data.data[pixel_offset] = shade;
image_data.data[pixel_offset + 1] = shade;
image_data.data[pixel_offset + 2] = shade;
image_data.data[pixel_offset + 3] = 255;
}
canvas_context.putImageData(image_data, 0, 0);
}
generate_text(length) {
let generated_text = '';
for (let index = 0; index < length; index++) {
generated_text += this.characters.charAt(
Math.floor(Math.random() * this.characters.length)
);
}
return generated_text;
}
resolve_difficulty(difficulty_input) {
const default_level = 3;
let normalized_level = Number(difficulty_input);
if (!Number.isFinite(normalized_level)) {
normalized_level = default_level;
}
normalized_level = Math.round(Math.min(6, Math.max(1, normalized_level)));
const base_length = 7;
const base_font_size = 20;
const base_rotation = 0.25;
const max_rotation = base_rotation * 3;
const base_noise_lines = 4;
const base_speckle_density = 0.01;
const base_jitter = 25;
const noise_line_step = 0.8;
const speckle_step = 0.0015;
const jitter_step = 1.6;
const rotation_step = (max_rotation - base_rotation) / 5;
const rotation = base_rotation + rotation_step * (normalized_level - 1);
const noise_lines = Math.round(
base_noise_lines + noise_line_step * (normalized_level - 1)
);
const speckle_density =
base_speckle_density + speckle_step * (normalized_level - 1);
const jitter = base_jitter + jitter_step * (normalized_level - 1);
return {
length: base_length,
noise_lines: noise_lines,
rotation: rotation,
jitter: jitter,
font_size: base_font_size,
speckle_density: speckle_density,
label: `level_${normalized_level}`,
level: normalized_level,
};
}
random_between(min, max) {
return Math.random() * (max - min) + min;
}
}
CaptchaPlugin.info = info;
export default CaptchaPlugin;