diff --git a/components/blockTargetKind.js b/components/blockTargetKind.js index a894a47d..f31445ba 100644 --- a/components/blockTargetKind.js +++ b/components/blockTargetKind.js @@ -15,6 +15,7 @@ export const switchKind = (targetKind, mappings) => { rsvpReading, movie, vernier, + readlab, } = mappings; switch (targetKind) { case "reading": @@ -47,6 +48,9 @@ export const switchKind = (targetKind, mappings) => { case "vernier": safeExecuteFunc(vernier); break; + case "readlab": + safeExecuteFunc(readlab); + break; default: break; } diff --git a/components/readlabBlock.js b/components/readlabBlock.js new file mode 100644 index 00000000..02c3006f --- /dev/null +++ b/components/readlabBlock.js @@ -0,0 +1,423 @@ +import { Scheduler } from "../psychojs/src/util"; +import { phrases } from "./i18n"; +import { thisExperimentInfo } from "./global"; +import { psychoJS } from "./globalPsychoJS"; +import { + loadRecruitmentServiceConfig, + recruitmentServiceData, +} from "./recruitmentService"; + +const READLAB_BASE_URL = "https://readlab.net/"; +const READLAB_CONFIG_DEFAULT = "config/sample-experiment.csv"; +const READLAB_BUTTON_ID = "readlab-continue-button"; +const READLAB_OVERLAY_ID = "readlab-overlay"; + +const DEFAULT_TEXT_EN = { + heading: "Continue on ReadLab", + body: + "For this part of the experiment, you will be redirected to ReadLab " + + "(readlab.net) to complete a reading task. When you finish there, " + + "ReadLab will bring you back here automatically. Please do not close " + + "this tab. Click the button below to continue.", + button: "Continue to ReadLab", +}; + +const phraseOrFallback = (phraseName, language, fallback) => { + try { + if ( + phrases && + Object.prototype.hasOwnProperty.call(phrases, phraseName) && + Object.prototype.hasOwnProperty.call(phrases[phraseName], language) + ) { + return phrases[phraseName][language]; + } + if ( + phrases && + Object.prototype.hasOwnProperty.call(phrases, phraseName) && + Object.prototype.hasOwnProperty.call(phrases[phraseName], "en") + ) { + return phrases[phraseName]["en"]; + } + } catch (_) {} + return fallback; +}; + +const safeReadParam = (paramReader, name, blockOrCondition, fallback) => { + try { + if (!paramReader || typeof paramReader.read !== "function") return fallback; + const value = paramReader.read(name, blockOrCondition); + if (value === undefined || value === null || value === "") return fallback; + return value; + } catch (_) { + return fallback; + } +}; + +export const buildReadlabRedirectUrl = ({ + config = READLAB_CONFIG_DEFAULT, + participantID = "", + returnUrl = window.location.href, +} = {}) => { + const params = new URLSearchParams(); + params.set("config", config); + params.set("participant", participantID || ""); + const url = `${READLAB_BASE_URL}?${params.toString()}`; + return `${url}&redirectUrl=${encodeURIComponent(returnUrl)}`; +}; + +export const buildEasyEyesReturnUrl = ({ + blockNumber, + experimentFilename, + participantID, + prolificParticipantID, + prolificStudyID, + prolificSessionID, + session, +} = {}) => { + const u = new URL(window.location.href); + u.searchParams.set("EE_resume", "1"); + if (typeof blockNumber === "number") + u.searchParams.set("EE_block", String(blockNumber)); + if (experimentFilename) u.searchParams.set("EE_experiment", experimentFilename); + if (participantID) u.searchParams.set("EE_participant", participantID); + if (session) u.searchParams.set("EE_session", String(session)); + if (prolificParticipantID) + u.searchParams.set("PROLIFIC_PID", prolificParticipantID); + if (prolificStudyID) u.searchParams.set("STUDY_ID", prolificStudyID); + if (prolificSessionID) u.searchParams.set("SESSION_ID", prolificSessionID); + u.searchParams.set("EE_t", String(Date.now())); + return u.toString(); +}; + +const removeOverlay = () => { + const existing = document.getElementById(READLAB_OVERLAY_ID); + if (existing) existing.remove(); +}; + +const renderReadlabOverlay = ({ heading, body, buttonLabel, onClick }) => { + removeOverlay(); + + const overlay = document.createElement("div"); + overlay.id = READLAB_OVERLAY_ID; + Object.assign(overlay.style, { + position: "fixed", + inset: "0", + background: "#ffffff", + color: "#222", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: "10000", + padding: "2rem", + fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", + }); + + const card = document.createElement("div"); + Object.assign(card.style, { + maxWidth: "640px", + width: "100%", + textAlign: "left", + lineHeight: "1.5", + }); + + const h1 = document.createElement("h1"); + h1.textContent = heading; + Object.assign(h1.style, { + fontSize: "1.75rem", + margin: "0 0 1rem 0", + }); + + const p = document.createElement("p"); + p.textContent = body; + Object.assign(p.style, { + fontSize: "1.1rem", + margin: "0 0 1.5rem 0", + }); + + const button = document.createElement("button"); + button.id = READLAB_BUTTON_ID; + button.type = "button"; + Object.assign(button.style, { + display: "inline-flex", + alignItems: "center", + gap: "0.5rem", + padding: "0.75rem 1.25rem", + fontSize: "1.05rem", + fontWeight: "600", + color: "#fff", + background: "#1a73e8", + border: "none", + borderRadius: "6px", + cursor: "pointer", + }); + button.addEventListener("mouseenter", () => { + button.style.background = "#1557b0"; + }); + button.addEventListener("mouseleave", () => { + button.style.background = "#1a73e8"; + }); + + const label = document.createElement("span"); + label.textContent = buttonLabel; + + // External-link SVG icon + const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + icon.setAttribute("width", "18"); + icon.setAttribute("height", "18"); + icon.setAttribute("viewBox", "0 0 24 24"); + icon.setAttribute("fill", "none"); + icon.setAttribute("stroke", "currentColor"); + icon.setAttribute("stroke-width", "2"); + icon.setAttribute("stroke-linecap", "round"); + icon.setAttribute("stroke-linejoin", "round"); + icon.setAttribute("aria-hidden", "true"); + icon.innerHTML = + '' + + '' + + ''; + + button.appendChild(label); + button.appendChild(icon); + button.addEventListener("click", onClick); + + card.appendChild(h1); + card.appendChild(p); + card.appendChild(button); + overlay.appendChild(card); + document.body.appendChild(overlay); +}; + +export const isReadlabResume = () => { + try { + const params = new URLSearchParams(window.location.search); + return params.get("EE_resume") === "1"; + } catch (_) { + return false; + } +}; + +const restoreExperimentInfoFromResumeUrl = () => { + try { + const p = new URLSearchParams(window.location.search); + const eeParticipant = p.get("EE_participant"); + if (eeParticipant) { + thisExperimentInfo.participant = eeParticipant; + thisExperimentInfo.EasyEyesID = eeParticipant; + thisExperimentInfo.PavloviaSessionID = eeParticipant; + } + const prolificPID = p.get("PROLIFIC_PID"); + const studyID = p.get("STUDY_ID"); + const sessionID = p.get("SESSION_ID"); + if (prolificPID) thisExperimentInfo.ProlificParticipantID = prolificPID; + if (studyID) thisExperimentInfo.ProlificStudyID = studyID; + if (sessionID) thisExperimentInfo.ProlificSessionID = sessionID; + } catch (_) {} +}; + +const renderReadlabReturnEnding = ({ + heading, + body, + buttonLabel, + buttonUrl, +}) => { + removeOverlay(); + const overlay = document.createElement("div"); + overlay.id = READLAB_OVERLAY_ID; + Object.assign(overlay.style, { + position: "fixed", + inset: "0", + background: "#ffffff", + color: "#222", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: "10000", + padding: "2rem", + fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", + }); + + const card = document.createElement("div"); + Object.assign(card.style, { + maxWidth: "640px", + width: "100%", + textAlign: "left", + lineHeight: "1.5", + }); + + const h1 = document.createElement("h1"); + h1.textContent = heading; + Object.assign(h1.style, { fontSize: "1.75rem", margin: "0 0 1rem 0" }); + + const p = document.createElement("p"); + p.textContent = body; + Object.assign(p.style, { fontSize: "1.1rem", margin: "0 0 1.5rem 0" }); + + card.appendChild(h1); + card.appendChild(p); + + if (buttonUrl) { + const button = document.createElement("button"); + button.id = READLAB_BUTTON_ID; + button.type = "button"; + button.textContent = buttonLabel; + Object.assign(button.style, { + display: "inline-flex", + alignItems: "center", + gap: "0.5rem", + padding: "0.75rem 1.25rem", + fontSize: "1.05rem", + fontWeight: "600", + color: "#fff", + background: "#1a73e8", + border: "none", + borderRadius: "6px", + cursor: "pointer", + }); + button.addEventListener("click", () => { + button.disabled = true; + window.location.href = buttonUrl; + }); + card.appendChild(button); + } + + overlay.appendChild(card); + document.body.appendChild(overlay); +}; + +export const showReadlabReturnEnding = async (language = "en") => { + restoreExperimentInfoFromResumeUrl(); + + // Make sure recruitment service config is loaded so we know whether to + // build a Prolific completion link. + try { + await loadRecruitmentServiceConfig(); + } catch (_) {} + + const heading = phraseOrFallback( + "EE_readlabReturnHeading", + language, + "Thank you!", + ); + const body = phraseOrFallback( + "EE_readlabReturnBody", + language, + "Your responses have been recorded. The experiment is complete.", + ); + + let buttonLabel = ""; + let buttonUrl = ""; + if ( + recruitmentServiceData && + recruitmentServiceData.name === "Prolific" && + recruitmentServiceData.url + ) { + buttonLabel = phraseOrFallback( + "EE_OKToTakeCompletionCodeToProlific", + language, + "Take me to Prolific to submit my completion code", + ); + buttonUrl = recruitmentServiceData.url; + } + + renderReadlabReturnEnding({ heading, body, buttonLabel, buttonUrl }); +}; + +export const readlabBlockBegin = (snapshot, paramReader) => { + return async function () { + const blockN = + snapshot && typeof snapshot.block === "number" ? snapshot.block + 1 : 1; + const language = "en"; + + const config = safeReadParam( + paramReader, + "readlabConfig", + blockN, + READLAB_CONFIG_DEFAULT, + ); + const customBody = safeReadParam( + paramReader, + "readlabInstructions", + blockN, + "", + ); + + const heading = phraseOrFallback( + "EE_readlabHeading", + language, + DEFAULT_TEXT_EN.heading, + ); + const body = + customBody || + phraseOrFallback("EE_readlabBody", language, DEFAULT_TEXT_EN.body); + const buttonLabel = phraseOrFallback( + "EE_readlabContinueButton", + language, + DEFAULT_TEXT_EN.button, + ); + + const returnUrl = buildEasyEyesReturnUrl({ + blockNumber: blockN, + experimentFilename: thisExperimentInfo.experimentFilename, + participantID: thisExperimentInfo.participant, + prolificParticipantID: thisExperimentInfo.ProlificParticipantID, + prolificStudyID: thisExperimentInfo.ProlificStudyID, + prolificSessionID: thisExperimentInfo.ProlificSessionID, + session: thisExperimentInfo.session, + }); + + const readlabUrl = buildReadlabRedirectUrl({ + config, + participantID: thisExperimentInfo.participant || "", + returnUrl, + }); + + renderReadlabOverlay({ + heading, + body, + buttonLabel, + onClick: async () => { + const btn = document.getElementById(READLAB_BUTTON_ID); + if (btn) btn.disabled = true; + + // Mark this navigation as intentional so the saveDataOnWindowClose + // beforeunload handler skips its preventDefault() (no "Leave site?" + // browser dialog). + window.__easyeyesIntentionalNavigation = true; + + // Remove PsychoJS's own beforeunload guard if present. + try { + if (psychoJS && psychoJS.beforeunloadCallback) { + window.removeEventListener( + "beforeunload", + psychoJS.beforeunloadCallback, + ); + } + } catch (_) {} + + // Save any pending experiment data before unloading the page. + try { + if (psychoJS && psychoJS.experiment) { + await psychoJS.experiment.save({ sync: true }); + } + } catch (_) {} + + window.location.href = readlabUrl; + }, + }); + + return Scheduler.Event.NEXT; + }; +}; + +export const readlabBlockEachFrame = () => { + return async function () { + return Scheduler.Event.FLIP_REPEAT; + }; +}; + +export const readlabBlockEnd = () => { + return async function () { + removeOverlay(); + return Scheduler.Event.NEXT; + }; +}; diff --git a/components/utils.js b/components/utils.js index c1a074a9..a9227c80 100644 --- a/components/utils.js +++ b/components/utils.js @@ -1522,6 +1522,10 @@ export const saveDataOnWindowClose = (experiment) => { experiment.save({ sync: true }); if (eyeTrackingStimulusRecords.length) experiment.saveCSV(eyeTrackingStimulusRecords); + // When EasyEyes itself is navigating the participant away on purpose + // (e.g. handing off to ReadLab), don't show the browser "Leave site?" + // dialog — but still save data above. + if (window.__easyeyesIntentionalNavigation) return; e.preventDefault(); return null; }); diff --git a/parameters/glossary.ts b/parameters/glossary.ts index 4a745c3d..0ced34dd 100644 --- a/parameters/glossary.ts +++ b/parameters/glossary.ts @@ -5054,6 +5054,7 @@ export const GLOSSARY: Glossary = { "reading", "rsvpReading", "repeatedLetters", + "readlab", ], }, targetLengthDeg: { diff --git a/threshold.js b/threshold.js index 1dc86250..74f18f05 100644 --- a/threshold.js +++ b/threshold.js @@ -393,6 +393,13 @@ import { /* ---------------------------------- */ import { switchKind, switchTask } from "./components/blockTargetKind.js"; +import { + readlabBlockBegin, + readlabBlockEachFrame, + readlabBlockEnd, + isReadlabResume, + showReadlabReturnEnding, +} from "./components/readlabBlock.js"; import { addSkipTrialButton, handleEscapeKey, @@ -652,6 +659,19 @@ const paramReaderInitialized = async (reader) => { // Fails gracefully if not actually prolific experiment, so run always saveProlificInfo(thisExperimentInfo); + // ReadLab return: participant has finished the readlab block and the + // browser has navigated back to this experiment URL with EE_resume=1. + // Skip the normal experiment flow (no calibration, no blocks) and show a + // thank-you with a Prolific completion link. PsychoJS is intentionally + // not started in this branch. + if (isReadlabResume()) { + const rootElement = document.getElementById("root"); + if (rootElement) rootElement.classList.add("initialized"); + if (window.removeLoadingScreen) window.removeLoadingScreen(); + await showReadlabReturnEnding(rc.language.value); + return; + } + setCurrentFn("paramReaderInitialized"); // ! avoid opening windows twice if (typeof psychoJS._window !== "undefined") return; @@ -2928,6 +2948,20 @@ const experiment = (howManyBlocksAreThereInTotal) => { blocksLoopScheduler.add(filterRoutineEachFrame()); blocksLoopScheduler.add(filterRoutineEnd()); + // ReadLab handoff block: render explanatory page + button, then + // navigate same-tab to readlab.net. No trials are scheduled. + // Intended to be the LAST block; the redirectUrl returns the + // participant to this experiment's URL with EE_resume params. + if (_thisBlock.targetKind === "readlab") { + blocksLoopScheduler.add(readlabBlockBegin(snapshot, paramReader)); + blocksLoopScheduler.add(readlabBlockEachFrame()); + blocksLoopScheduler.add(readlabBlockEnd()); + blocksLoopScheduler.add( + endLoopIteration(blocksLoopScheduler, snapshot), + ); + continue; + } + // DELETE // if ( // conditions.every(