working prototype
This commit is contained in:
303
plugins/jspsych-captcha.js
Normal file
303
plugins/jspsych-captcha.js
Normal 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;
|
||||
209
plugins/jspsych-die-roll.js
Normal file
209
plugins/jspsych-die-roll.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import { ParameterType } from 'jspsych';
|
||||
|
||||
const info = {
|
||||
name: 'die-roll',
|
||||
parameters: {
|
||||
prompt: {
|
||||
type: ParameterType.HTML_STRING,
|
||||
default: '',
|
||||
},
|
||||
rigged_value: {
|
||||
type: ParameterType.INT,
|
||||
default: null,
|
||||
},
|
||||
roll_duration: {
|
||||
type: ParameterType.INT,
|
||||
default: 2000,
|
||||
},
|
||||
animation_interval: {
|
||||
type: ParameterType.INT,
|
||||
default: 120,
|
||||
},
|
||||
button_label: {
|
||||
type: ParameterType.STRING,
|
||||
default: 'Continue',
|
||||
},
|
||||
result_template: {
|
||||
type: ParameterType.STRING,
|
||||
default: 'You rolled a {{value}}.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
class DieRollPlugin {
|
||||
constructor(jsPsych) {
|
||||
this.jsPsych = jsPsych;
|
||||
}
|
||||
|
||||
trial(display_element, trial) {
|
||||
const start_time = performance.now();
|
||||
const sanitized_rigged_value = this.get_rigged_value(trial.rigged_value);
|
||||
const final_value =
|
||||
sanitized_rigged_value ??
|
||||
this.jsPsych.randomization.sampleWithoutReplacement(
|
||||
[1, 2, 3, 4, 5, 6],
|
||||
1
|
||||
)[0];
|
||||
|
||||
display_element.innerHTML = this.build_markup(
|
||||
trial.prompt,
|
||||
trial.button_label
|
||||
);
|
||||
|
||||
const face_element = display_element.querySelector(
|
||||
'.jspsych_die_roll_face'
|
||||
);
|
||||
const result_element = display_element.querySelector(
|
||||
'.jspsych_die_roll_result'
|
||||
);
|
||||
const button_element = display_element.querySelector(
|
||||
'.jspsych_die_roll_button'
|
||||
);
|
||||
|
||||
let is_rolling = false;
|
||||
let has_rolled = false;
|
||||
let roll_started_at = null;
|
||||
let roll_stopped_at = null;
|
||||
let die_click_count = 0;
|
||||
|
||||
const clear_timeouts = () => {
|
||||
this.jsPsych.pluginAPI.clearAllTimeouts();
|
||||
};
|
||||
|
||||
const cycle_face = () => {
|
||||
if (!is_rolling) {
|
||||
return;
|
||||
}
|
||||
const value = this.random_face(final_value);
|
||||
face_element.textContent = value;
|
||||
this.jsPsych.pluginAPI.setTimeout(cycle_face, trial.animation_interval);
|
||||
};
|
||||
|
||||
const finalize_roll = () => {
|
||||
if (!is_rolling || has_rolled) {
|
||||
return;
|
||||
}
|
||||
is_rolling = false;
|
||||
has_rolled = true;
|
||||
die_click_count += 1;
|
||||
roll_stopped_at = performance.now();
|
||||
clear_timeouts();
|
||||
face_element.textContent = final_value;
|
||||
face_element.classList.remove('jspsych_die_roll_face--active');
|
||||
face_element.classList.remove('jspsych_die_roll_face--interactive');
|
||||
face_element.classList.add('jspsych_die_roll_face--locked');
|
||||
face_element.setAttribute('aria-disabled', 'true');
|
||||
face_element.removeAttribute('tabindex');
|
||||
face_element.style.pointerEvents = 'none';
|
||||
result_element.textContent = trial.result_template.replace(
|
||||
/\{\{value\}\}/gi,
|
||||
final_value
|
||||
);
|
||||
button_element.disabled = false;
|
||||
button_element.focus();
|
||||
};
|
||||
|
||||
const begin_roll = () => {
|
||||
if (is_rolling || has_rolled) {
|
||||
return;
|
||||
}
|
||||
is_rolling = true;
|
||||
die_click_count += 1;
|
||||
roll_started_at = performance.now();
|
||||
result_element.textContent = 'Rolling... click again to stop the die.';
|
||||
face_element.classList.add('jspsych_die_roll_face--active');
|
||||
button_element.disabled = true;
|
||||
clear_timeouts();
|
||||
cycle_face();
|
||||
};
|
||||
|
||||
const handle_face_interaction = () => {
|
||||
if (!is_rolling && !has_rolled) {
|
||||
begin_roll();
|
||||
} else if (is_rolling && !has_rolled) {
|
||||
finalize_roll();
|
||||
}
|
||||
};
|
||||
|
||||
button_element.disabled = true;
|
||||
face_element.textContent = '';
|
||||
result_element.textContent =
|
||||
'Click the die to start, then click it again to lock in your number.';
|
||||
face_element.classList.add('jspsych_die_roll_face--interactive');
|
||||
face_element.setAttribute('tabindex', '0');
|
||||
face_element.setAttribute('role', 'button');
|
||||
face_element.setAttribute(
|
||||
'aria-label',
|
||||
'Virtual die. Click to start and stop.'
|
||||
);
|
||||
|
||||
face_element.addEventListener('click', handle_face_interaction);
|
||||
face_element.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handle_face_interaction();
|
||||
}
|
||||
});
|
||||
|
||||
const end_trial = () => {
|
||||
is_rolling = false;
|
||||
clear_timeouts();
|
||||
const trial_data = {
|
||||
roll: final_value,
|
||||
rigged_value: sanitized_rigged_value,
|
||||
rigged: sanitized_rigged_value !== null,
|
||||
die_click_count: die_click_count,
|
||||
roll_started_after_ms: roll_started_at
|
||||
? roll_started_at - start_time
|
||||
: null,
|
||||
roll_duration_ms:
|
||||
roll_started_at && roll_stopped_at
|
||||
? roll_stopped_at - roll_started_at
|
||||
: null,
|
||||
rt: performance.now() - start_time,
|
||||
};
|
||||
display_element.innerHTML = '';
|
||||
this.jsPsych.finishTrial(trial_data);
|
||||
};
|
||||
|
||||
button_element.addEventListener('click', () => {
|
||||
if (!button_element.disabled) {
|
||||
end_trial();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
build_markup(prompt, button_label) {
|
||||
const prompt_html = prompt
|
||||
? `<div class="jspsych_die_roll_prompt">${prompt}</div>`
|
||||
: '';
|
||||
return `
|
||||
<div class="jspsych_die_roll">
|
||||
${prompt_html}
|
||||
<div class="jspsych_die_roll_face">?</div>
|
||||
<div class="jspsych_die_roll_result" aria-live="polite"></div>
|
||||
<button class="jspsych-btn jspsych_die_roll_button" type="button">${button_label}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
random_face(exclude_value) {
|
||||
let value = Math.floor(Math.random() * 6) + 1;
|
||||
if (value === exclude_value) {
|
||||
value = (value % 6) + 1;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
get_rigged_value(value) {
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric) && numeric >= 1 && numeric <= 6) {
|
||||
return Math.round(numeric);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
DieRollPlugin.info = info;
|
||||
|
||||
export default DieRollPlugin;
|
||||
Reference in New Issue
Block a user