Spaces:
Running
Running
Download index.html from Oshikaka/edit-preference-study: direct link, hf CLI and curl.
- Browser
- Download file 46.5 kB
-
https://huggingface.co/spaces/Oshikaka/edit-preference-study/resolve/main/index.html
- Command line
-
hf download hf://spaces/Oshikaka/edit-preference-study/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Oshikaka/edit-preference-study/resolve/main/index.html
46.5 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> | |
| <style>html, body { margin: 0; } img { max-width: 100%; } [hidden] { display: none ; }</style> | |
| <title>Pick the Better Edit</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Mono:wght@400;600&family=Atkinson+Hyperlegible+Next:wght@400;600;800&display=swap"> | |
| <style> | |
| /* Light palette. The media stage is the same neutral gray in both themes on purpose: colour judgements | |
| (coffee -> matcha, gray suit -> tan) need a neutral surround, as in subjective image-quality labs. */ | |
| :root { | |
| --bg: #f1f1f0; | |
| --surface: #ffffff; | |
| --ink: #17171a; | |
| --muted: #5d5d66; | |
| --line: #d6d6da; | |
| --soft: #e7e7ea; | |
| --src: #1d5bbf; /* source words, as in the paper's figures */ | |
| --tgt: #b3261e; /* target words */ | |
| --focus: #1d5bbf; | |
| --stage: #3b3b3b; | |
| --stage-ink: #ececec; | |
| --stage-muted: #b4b4b4; | |
| --sel-bg: #17171a; | |
| --sel-ink: #ffffff; | |
| --ok: #1e7a3c; | |
| --warn: #9a5b00; | |
| --sans: "Atkinson Hyperlegible Next", "Atkinson Hyperlegible", system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; | |
| --mono: "Atkinson Hyperlegible Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| :root:not([data-theme="light"]) { | |
| color-scheme: dark; | |
| --bg: #141415; --surface: #1d1d1f; --ink: #ececef; --muted: #a2a2ab; --line: #34343a; --soft: #26262a; | |
| --src: #86aef7; --tgt: #ff8f84; --focus: #86aef7; --sel-bg: #ececef; --sel-ink: #141415; --ok: #6fcf8e; --warn: #f0b35a; | |
| } | |
| } | |
| :root[data-theme="dark"] { | |
| color-scheme: dark; | |
| --bg: #141415; --surface: #1d1d1f; --ink: #ececef; --muted: #a2a2ab; --line: #34343a; --soft: #26262a; | |
| --src: #86aef7; --tgt: #ff8f84; --focus: #86aef7; --sel-bg: #ececef; --sel-ink: #141415; --ok: #6fcf8e; --warn: #f0b35a; | |
| } | |
| * { box-sizing: border-box; } | |
| [hidden] { display: none ; } | |
| html, body { background: var(--bg); color: var(--ink); } | |
| body { font-family: var(--sans); font-size: 16px; line-height: 1.5; padding-inline: 16px; padding-block: 0 48px; } | |
| h1, h2, h3 { text-wrap: balance; margin: 0; } | |
| button, input, select, textarea { font: inherit; color: inherit; } | |
| :focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } | |
| .wrap { max-width: 1240px; margin-inline: auto; } | |
| .narrow { max-width: 680px; margin-inline: auto; } | |
| .eyebrow { font-size: 12px; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); font-weight: 600; } | |
| .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } | |
| .btn { appearance: none; border: 1.5px solid var(--ink); background: var(--surface); border-radius: 8px; padding: 10px 18px; cursor: pointer; font-weight: 600; } | |
| .btn:hover:not(:disabled) { background: var(--soft); } | |
| .btn.primary { background: var(--sel-bg); color: var(--sel-ink); border-color: var(--sel-bg); } | |
| .btn.primary:hover:not(:disabled) { opacity: .88; background: var(--sel-bg); } | |
| .btn.quiet { border-color: var(--line); font-weight: 400; padding: 7px 12px; } | |
| .btn:disabled { opacity: .4; cursor: default; } | |
| .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } | |
| .seg button { border: 0; background: transparent; padding: 6px 12px; cursor: pointer; } | |
| .seg button[aria-pressed="true"] { background: var(--sel-bg); color: var(--sel-ink); } | |
| .w-src { color: var(--src); font-weight: 600; } | |
| .w-tgt { color: var(--tgt); font-weight: 600; } | |
| .arrow { color: var(--muted); padding-inline: .35em; } | |
| /* ---------- welcome ---------- */ | |
| #welcome { padding-block: 40px 0; display: grid; gap: 28px; } | |
| .topline { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; } | |
| .title { font-size: clamp(34px, 6vw, 56px); font-weight: 800; letter-spacing: -.02em; line-height: 1.04; } | |
| .lede { font-size: 19px; max-width: 62ch; } | |
| .facts { display: flex; flex-wrap: wrap; gap: 8px 22px; color: var(--muted); } | |
| .facts b { color: var(--ink); font-family: var(--mono); font-weight: 600; } | |
| .qs { display: grid; gap: 14px; border-top: 1px solid var(--line); padding-top: 22px; } | |
| .qrow { display: grid; grid-template-columns: 34px 1fr; gap: 12px; align-items: start; } | |
| .qbadge { width: 30px; height: 30px; border-radius: 50%; border: 1.5px solid var(--ink); display: grid; place-items: center; font-weight: 800; font-size: 14px; } | |
| .qrow h3 { font-size: 18px; } | |
| .qrow p { margin: 2px 0 0; color: var(--muted); max-width: 62ch; } | |
| .rules { margin: 0; padding-left: 20px; display: grid; gap: 6px; max-width: 64ch; } | |
| .form { display: grid; gap: 16px; border-top: 1px solid var(--line); padding-top: 22px; } | |
| .form label { display: grid; gap: 6px; font-weight: 600; } | |
| .form input[type=text] { border: 1px solid var(--line); background: var(--surface); border-radius: 8px; padding: 10px 12px; max-width: 360px; font-weight: 400; } | |
| .radios { display: flex; flex-wrap: wrap; gap: 8px; font-weight: 400; } | |
| .radios label { display: inline-flex; gap: 6px; align-items: center; border: 1px solid var(--line); border-radius: 999px; padding: 6px 14px; cursor: pointer; font-weight: 400; background: var(--surface); } | |
| .radios input { accent-color: var(--ink); } | |
| .startbar { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; } | |
| .keys { color: var(--muted); font-size: 14px; } | |
| kbd { font-family: var(--mono); font-size: 12px; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px; padding: 0 5px; background: var(--surface); color: var(--ink); } | |
| #organizer { border: 1px dashed var(--line); border-radius: 10px; padding: 16px; display: grid; gap: 10px; background: var(--surface); } | |
| #organizer textarea { width: 100%; min-height: 80px; border: 1px solid var(--line); border-radius: 8px; padding: 8px; background: var(--bg); font-family: var(--mono); font-size: 13px; } | |
| #orgStats { font-family: var(--mono); font-size: 14px; } | |
| /* ---------- trial ---------- */ | |
| #trial { display: grid; gap: 14px; } | |
| .bar { position: sticky; top: env(safe-area-inset-top, 0px); z-index: 5; background: var(--bg); padding-block: 12px 8px; display: grid; gap: 8px; } | |
| .barrow { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: space-between; } | |
| .where { display: flex; gap: 12px; align-items: baseline; } | |
| .where .part { font-weight: 800; } | |
| .track { height: 4px; background: var(--soft); border-radius: 2px; overflow: hidden; } | |
| .track i { display: block; height: 100%; background: var(--ink); width: 0; transition: width .25s; } | |
| .barbtns { display: flex; gap: 8px; flex-wrap: wrap; } | |
| .editcard { display: grid; gap: 6px; } | |
| .instr { font-size: clamp(20px, 2.6vw, 28px); font-weight: 600; line-height: 1.25; } | |
| .desc { color: var(--muted); font-size: 14px; max-width: 110ch; } | |
| .desc .w-tgt { font-weight: 600; } | |
| .grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } | |
| .stage { background: var(--stage); border-radius: 10px; padding: 12px; } | |
| .cell { display: grid; gap: 6px; } | |
| .cell .lab { color: var(--stage-ink); font-weight: 800; font-size: 15px; display: flex; justify-content: space-between; } | |
| .cell .lab span { color: var(--stage-muted); font-weight: 400; font-size: 13px; } | |
| .media { width: 100%; max-width: 100%; background-color: #2c2c2c; background-repeat: no-repeat; border-radius: 4px; cursor: zoom-in; display: block; } | |
| canvas.media { height: auto; } | |
| .cell.orig .lab { color: var(--stage-muted); } | |
| .vctl { display: flex; gap: 10px; align-items: center; color: var(--stage-ink); padding-top: 10px; flex-wrap: wrap; } | |
| .vctl button { background: transparent; color: var(--stage-ink); border: 1px solid #6a6a6a; border-radius: 6px; padding: 4px 10px; cursor: pointer; } | |
| .vctl button[aria-pressed="true"] { background: var(--stage-ink); color: var(--stage); } | |
| .vctl input[type=range] { flex: 1; min-width: 120px; accent-color: var(--stage-ink); } | |
| .vctl .fr { font-family: var(--mono); font-size: 13px; color: var(--stage-muted); min-width: 64px; } | |
| .loading { color: var(--stage-muted); font-size: 14px; padding: 6px 0 0; min-height: 1.5em; } | |
| .stage.zoomed { position: fixed; inset: 0; z-index: 50; border-radius: 0; padding: max(12px, env(safe-area-inset-top, 0px)) 16px 16px; overflow: auto; display: grid; align-content: center; } | |
| .stage.zoomed .media { cursor: zoom-out; } | |
| .zoomnote { color: var(--stage-muted); font-size: 13px; text-align: center; padding-top: 8px; } | |
| .answers { display: grid; gap: 10px; } | |
| .arow { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; align-items: center; padding: 10px 12px; border-radius: 10px; border: 1.5px solid transparent; } | |
| .arow.active { border-color: var(--line); background: var(--surface); } | |
| .arow .qt { font-weight: 600; } | |
| .arow .qt small { display: block; font-weight: 400; color: var(--muted); font-size: 13px; line-height: 1.35; margin-top: 2px; } | |
| .pick { appearance: none; border: 1.5px solid var(--line); background: var(--surface); border-radius: 8px; padding: 12px; cursor: pointer; font-weight: 800; font-size: 17px; width: 100%; } | |
| .pick:hover:not(:disabled) { border-color: var(--ink); } | |
| .pick[aria-pressed="true"] { background: var(--sel-bg); color: var(--sel-ink); border-color: var(--sel-bg); } | |
| .pick:disabled { opacity: .35; cursor: default; } | |
| .navrow { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; } | |
| .gate { color: var(--warn); font-size: 14px; } | |
| .saved { font-size: 13px; color: var(--muted); } | |
| .saved.ok { color: var(--ok); } | |
| /* ---------- break / done ---------- */ | |
| .panel { padding-block: 56px 0; display: grid; gap: 18px; } | |
| .codebox { font-family: var(--mono); font-size: 14px; word-break: break-all; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 14px; user-select: all; } | |
| .status { font-weight: 600; } | |
| .status.ok { color: var(--ok); } | |
| @media (max-width: 760px) { | |
| .grid3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| .grid3 .cell.orig { grid-column: 1 / -1; } | |
| .grid3 .cell.orig .media { max-width: 70%; justify-self: center; } | |
| .arow { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| .arow .qt { grid-column: 1 / -1; } | |
| .instr { font-size: 20px; } | |
| } | |
| @media (prefers-reduced-motion: reduce) { .track i { transition: none; } } | |
| </style> | |
| </head> | |
| <body> | |
| <main class="wrap"> | |
| <section id="welcome" class="narrow"> | |
| <div class="topline"> | |
| <span class="eyebrow" data-t="eyebrow"></span> | |
| <div class="seg" role="group" aria-label="Language"> | |
| <button type="button" id="lang-en" data-lang="en">English</button> | |
| <button type="button" id="lang-zh" data-lang="zh">中文</button> | |
| </div> | |
| </div> | |
| <h1 class="title" data-t="title"></h1> | |
| <p class="lede" data-t="intro"></p> | |
| <div class="facts" id="facts"></div> | |
| <div class="qs"> | |
| <div class="qrow"><div class="qbadge">1</div><div><h3 data-t="q1"></h3><p data-t="q1hint"></p></div></div> | |
| <div class="qrow"><div class="qbadge">2</div><div><h3 data-t="q2"></h3><p data-t="q2hint"></p></div></div> | |
| <ul class="rules"> | |
| <li data-t="rule1"></li> | |
| <li data-t="rule2"></li> | |
| <li data-t="rule3"></li> | |
| </ul> | |
| </div> | |
| <div class="form"> | |
| <label for="pname"><span data-t="name"></span> | |
| <input type="text" id="pname" autocomplete="off" maxlength="60"> | |
| </label> | |
| <div style="display:grid;gap:8px"> | |
| <span style="font-weight:600" data-t="exp"></span> | |
| <div class="radios" id="exp"> | |
| <label><input type="radio" name="exp" id="exp0" value="0"><span data-t="exp0"></span></label> | |
| <label><input type="radio" name="exp" id="exp1" value="1"><span data-t="exp1"></span></label> | |
| <label><input type="radio" name="exp" id="exp2" value="2"><span data-t="exp2"></span></label> | |
| </div> | |
| </div> | |
| <div class="startbar"> | |
| <button type="button" class="btn primary" id="start" disabled></button> | |
| <button type="button" class="btn quiet" id="restart" hidden data-t="restart"></button> | |
| </div> | |
| <div class="keys" id="keys"></div> | |
| <div class="keys" id="loadmsg"></div> | |
| </div> | |
| <section id="organizer" hidden> | |
| <div class="eyebrow">Organizer · only you see this</div> | |
| <div id="orgStats">…</div> | |
| <label for="codesIn" style="font-weight:600">Paste participant codes (one per line) to keep them with the study</label> | |
| <textarea id="codesIn" spellcheck="false" placeholder="PBE1.xxxxxx.1.ABC…"></textarea> | |
| <div class="startbar"><button type="button" class="btn quiet" id="addCodes">Add codes</button><span id="codeMsg" class="saved"></span></div> | |
| <p class="keys" style="margin:0">Answers save here automatically for people who open the page signed in with Contributor access. Everyone else gets a code at the end to send you.</p> | |
| </section> | |
| </section> | |
| <section id="trial" hidden> | |
| <div class="bar"> | |
| <div class="barrow"> | |
| <div class="where"><span class="part" id="partName"></span><span class="mono" id="count"></span><span class="saved" id="savedMsg"></span></div> | |
| <div class="barbtns"> | |
| <button type="button" class="btn quiet" id="zoomBtn" data-t="enlarge"></button> | |
| <button type="button" class="btn quiet" id="stopBtn" data-t="stop"></button> | |
| </div> | |
| </div> | |
| <div class="track"><i id="trackFill"></i></div> | |
| </div> | |
| <div class="editcard"> | |
| <span class="eyebrow" data-t="editLabel"></span> | |
| <div class="instr" id="instr"></div> | |
| <div class="desc" id="desc"></div> | |
| </div> | |
| <div class="stage" id="stage"> | |
| <div class="grid3"> | |
| <div class="cell orig"><div class="lab" id="origLab"></div><div id="m0"></div></div> | |
| <div class="cell"><div class="lab">A</div><div id="m1"></div></div> | |
| <div class="cell"><div class="lab">B</div><div id="m2"></div></div> | |
| </div> | |
| <div class="vctl" id="vctl" hidden> | |
| <button type="button" id="playBtn"></button> | |
| <button type="button" id="slowBtn" aria-pressed="false">0.5×</button> | |
| <input type="range" id="scrub" min="0" max="40" value="0" aria-label="Frame"> | |
| <span class="fr" id="frameNo"></span> | |
| </div> | |
| <div class="loading" id="stageMsg"></div> | |
| <div class="zoomnote" id="zoomNote" hidden data-t="zoomNote"></div> | |
| </div> | |
| <div class="answers"> | |
| <div class="arow" id="row1"> | |
| <div class="qt"><span data-t="q1"></span><small data-t="q1short"></small></div> | |
| <button type="button" class="pick" id="q1L">A</button> | |
| <button type="button" class="pick" id="q1R">B</button> | |
| </div> | |
| <div class="arow" id="row2"> | |
| <div class="qt"><span data-t="q2"></span><small data-t="q2short"></small></div> | |
| <button type="button" class="pick" id="q2L">A</button> | |
| <button type="button" class="pick" id="q2R">B</button> | |
| </div> | |
| </div> | |
| <div class="navrow"> | |
| <button type="button" class="btn quiet" id="backBtn" data-t="back"></button> | |
| <span class="gate" id="gateMsg"></span> | |
| <button type="button" class="btn primary" id="nextBtn" data-t="next"></button> | |
| </div> | |
| </section> | |
| <section id="break" class="panel narrow" hidden> | |
| <span class="eyebrow" data-t="halfway"></span> | |
| <h2 class="title" style="font-size:40px" data-t="breakTitle"></h2> | |
| <p class="lede" id="breakBody"></p> | |
| <div class="startbar"><button type="button" class="btn primary" id="toVideos" data-t="toVideos"></button></div> | |
| </section> | |
| <section id="done" class="panel narrow" hidden> | |
| <span class="eyebrow" data-t="eyebrow"></span> | |
| <h2 class="title" style="font-size:44px" data-t="doneTitle"></h2> | |
| <p class="status" id="doneStatus"></p> | |
| <p id="doneBody" data-t="sendCode"></p> | |
| <div class="codebox" id="codeBox"></div> | |
| <div class="startbar"> | |
| <button type="button" class="btn primary" id="copyBtn" data-t="copy"></button> | |
| <span class="saved" id="copyMsg"></span> | |
| <button type="button" class="btn quiet" id="reviewBtn" data-t="review"></button> | |
| </div> | |
| </section> | |
| </main> | |
| <script> | |
| ; | |
| const STR = { | |
| en: { | |
| eyebrow: "Image & video editing study", | |
| title: "Pick the better edit", | |
| intro: "Each screen shows an original image or video, an edit request, and two results, A and B, made by two different editing methods. Compare A with B and answer two questions.", | |
| facts: "<b>{ni}</b> image pairs · then <b>{nv}</b> video pairs · about <b>{min}</b> minutes · your progress stays in this browser", | |
| factsImg: "<b>{ni}</b> image pairs · about <b>{min}</b> minutes · your progress stays in this browser", | |
| q1: "Which result makes the requested edit better?", | |
| q1hint: "Look only at the edit: did the change asked for actually happen, and how completely?", | |
| q1short: "Did the requested change happen?", | |
| q2: "Overall, which result do you prefer?", | |
| q2hint: "Weigh everything: the edit is done, the result looks natural, and what the request does not mention stays as in the original.", | |
| q2short: "Edit done, looks natural, the rest kept.", | |
| rule1: "A result that looks the same as the original has not made the edit.", | |
| rule2: "Method names are hidden and A and B are shuffled on every screen. There are no right answers.", | |
| rule3: "Click any picture to enlarge it. Videos loop; you can slow them down or drag through the frames.", | |
| name: "Name or initials (optional)", | |
| exp: "How familiar are you with AI image or video editing?", | |
| exp0: "Not at all", exp1: "A little", exp2: "Very", | |
| start: "Start", resume: "Continue ({n} of {N} done)", restart: "Start over", | |
| keys: "Keys: <kbd>A</kbd>/<kbd>B</kbd> or <kbd>←</kbd>/<kbd>→</kbd> answer the highlighted question · <kbd>Enter</kbd> next · <kbd>Backspace</kbd> back · <kbd>Space</kbd> play or pause · <kbd>Z</kbd> enlarge", | |
| partImg: "Images", partVid: "Videos", | |
| editLabel: "Edit request", | |
| orig: "Original", origV: "Original", | |
| enlarge: "Enlarge", shrink: "Close", stop: "Stop here and get my code", | |
| zoomNote: "Press Z or Esc, or click a picture, to close.", | |
| next: "Next", back: "Back", finish: "Finish", | |
| watch: "Watch the clip once before answering.", | |
| loading: "Loading…", loadFail: "This file did not load. Check your connection and press Next to skip it.", | |
| play: "Pause", paused: "Play", | |
| target: "Target description:", | |
| halfway: "Part 1 of 2 done", | |
| breakTitle: "Images done. Now videos.", | |
| breakBody: "Next come {nv} video pairs. Each clip is about 2.5 seconds and loops. A, B and the original play in sync.", | |
| toVideos: "Start the videos", | |
| doneTitle: "Thank you", | |
| savedOk: "Your answers are saved.", | |
| savedPartial: "{n} of {N} answered.", | |
| sendCode: "Please send this code to the person who shared the study with you. It contains your answers and nothing else.", | |
| keepCode: "Nothing else to do. The code below is a backup of your answers; send it only if the organizer asks.", | |
| copy: "Copy code", copied: "Copied", copyFail: "Select the code above and copy it.", | |
| review: "Back to the last pair", continue: "Continue where I stopped", skip: "Skip this one", | |
| savedSync: "saved", savedLocal: "kept in this browser", | |
| loadingData: "Loading the study…", dataFail: "The study data did not load. Reload the page to try again.", | |
| }, | |
| zh: { | |
| eyebrow: "图像与视频编辑用户研究", | |
| title: "选出更好的编辑", | |
| intro: "每一页给出一张原图或一段原视频、一个编辑要求,以及两种不同编辑方法给出的结果 A 和 B。请比较 A 和 B,回答两个问题。", | |
| facts: "<b>{ni}</b> 组图片 · 然后 <b>{nv}</b> 组视频 · 约 <b>{min}</b> 分钟 · 进度保存在本浏览器中", | |
| factsImg: "<b>{ni}</b> 组图片 · 约 <b>{min}</b> 分钟 · 进度保存在本浏览器中", | |
| q1: "哪个结果更好地完成了要求的编辑?", | |
| q1hint: "只看编辑本身:要求的变化有没有发生,完成得是否彻底。", | |
| q1short: "要求的变化有没有发生?", | |
| q2: "综合来看,你更喜欢哪个结果?", | |
| q2hint: "综合考虑:编辑是否完成、结果是否自然、要求中没有提到的部分是否和原图一致。", | |
| q2short: "编辑完成、自然、其余部分保持不变。", | |
| rule1: "和原图几乎一样的结果,说明没有完成编辑。", | |
| rule2: "方法名称已隐藏,每一页的 A、B 顺序都是随机的。没有标准答案。", | |
| rule3: "点击任意图片可以放大。视频循环播放,可以放慢或拖动逐帧查看。", | |
| name: "姓名或缩写(选填)", | |
| exp: "你对 AI 图像或视频编辑熟悉吗?", | |
| exp0: "不了解", exp1: "了解一些", exp2: "很熟悉", | |
| start: "开始", resume: "继续(已完成 {n} / {N})", restart: "重新开始", | |
| keys: "快捷键:<kbd>A</kbd>/<kbd>B</kbd> 或 <kbd>←</kbd>/<kbd>→</kbd> 回答高亮的问题 · <kbd>Enter</kbd> 下一题 · <kbd>Backspace</kbd> 上一题 · <kbd>空格</kbd> 播放或暂停 · <kbd>Z</kbd> 放大", | |
| partImg: "图片", partVid: "视频", | |
| editLabel: "编辑要求", | |
| orig: "原图", origV: "原视频", | |
| enlarge: "放大", shrink: "关闭", stop: "到此结束并获取代码", | |
| zoomNote: "按 Z 或 Esc,或点击图片关闭。", | |
| next: "下一题", back: "上一题", finish: "完成", | |
| watch: "请先完整看一遍视频再作答。", | |
| loading: "加载中…", loadFail: "文件没有加载成功。请检查网络,或点“下一题”跳过。", | |
| play: "暂停", paused: "播放", | |
| target: "目标描述:", | |
| halfway: "第 1 部分(共 2 部分)完成", | |
| breakTitle: "图片部分完成,接下来是视频。", | |
| breakBody: "接下来是 {nv} 组视频。每段约 2.5 秒,循环播放;原视频、A、B 同步播放。", | |
| toVideos: "开始视频部分", | |
| doneTitle: "谢谢参与", | |
| savedOk: "你的答案已保存。", | |
| savedPartial: "已回答 {n} / {N}。", | |
| sendCode: "请把下面的代码发给邀请你参加的人。代码里只有你的答案。", | |
| keepCode: "不需要再做什么了。下面的代码是你答案的备份,组织者需要时再发给他们。", | |
| copy: "复制代码", copied: "已复制", copyFail: "请选中上面的代码手动复制。", | |
| review: "回到最后一题", continue: "从停下的地方继续", skip: "跳过这一题", | |
| savedSync: "已保存", savedLocal: "已保存在本浏览器", | |
| loadingData: "正在加载…", dataFail: "数据没有加载成功,请刷新页面重试。", | |
| }, | |
| }; | |
| const ALPH = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn"; // 40 symbols: v*8 + s*4 + q1*2 + q2 | |
| // "#extra" = only the trials marked x (the random SPRITE set added 11-07 + one catch), for people who already did the rest | |
| const MODE = location.hash === "#extra" ? "extra" : "full"; | |
| const LS_KEY = "pbe-study-state" + (MODE === "extra" ? "-extra" : ""); | |
| const $ = (id) => document.getElementById(id); | |
| const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); | |
| let D = null, T = {}, IDX = {}; | |
| let S = null; // participant state | |
| let lang = "en"; | |
| let cur = null; // current trial view: {tid, t, L, R, shownAt, watched, video, stop} | |
| let activeQ = 1, advTimer = null; | |
| const blobs = new Map(); // prefetched video blobs | |
| let dbDoc = null, docExists = false, writing = false, dirty = false, syncTimer = null, dbState = "none", begun = false; | |
| const runKey = () => `${D.version}_${MODE}`; | |
| const modeTrials = () => D.trials.filter((t) => MODE === "full" || t.x); | |
| function tr(k, vars) { | |
| let s = (STR[lang] && STR[lang][k]) || STR.en[k] || k; | |
| if (vars) for (const [a, b] of Object.entries(vars)) s = s.split("{" + a + "}").join(b); | |
| return s; | |
| } | |
| function lsGet() { try { return JSON.parse(localStorage.getItem(LS_KEY) || "null"); } catch (e) { return null; } } | |
| function lsSet() { try { localStorage.setItem(LS_KEY, JSON.stringify(S)); } catch (e) { /* storage blocked: keep going */ } } | |
| function lsDel() { try { localStorage.removeItem(LS_KEY); } catch (e) {} } | |
| function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; } | |
| function shuffle(arr, rnd) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } | |
| function segs(list) { return (list || []).map(([txt, cls]) => cls ? `<span class="${cls === "arrow" ? "arrow" : "w-" + cls}">${esc(txt)}</span>` : esc(txt)).join(""); } | |
| /* ---------- text ---------- */ | |
| function applyLang() { | |
| document.documentElement.lang = lang === "zh" ? "zh-CN" : "en"; | |
| document.querySelectorAll("[data-t]").forEach((el) => { el.innerHTML = tr(el.dataset.t); }); | |
| $("lang-en").setAttribute("aria-pressed", String(lang === "en")); | |
| $("lang-zh").setAttribute("aria-pressed", String(lang === "zh")); | |
| $("keys").innerHTML = tr("keys"); | |
| if (D) { | |
| const T0 = modeTrials(), ni = T0.filter((t) => t.kind === "image").length, nv = T0.length - ni; | |
| const min = Math.max(5, Math.round((ni * 9 + nv * 14) / 60 / 5) * 5); | |
| $("facts").innerHTML = nv ? tr("facts", { ni, nv, min }) : tr("factsImg", { ni, min }); | |
| $("breakBody").textContent = tr("breakBody", { nv }); | |
| } | |
| setStartLabel(); | |
| if (cur) renderTrialText(); | |
| } | |
| function setStartLabel() { | |
| const b = $("start"); | |
| if (!D) { b.textContent = tr("start"); return; } | |
| const n = nDone(); | |
| b.textContent = n > 0 ? tr("resume", { n, N: modeTrials().length }) : tr("start"); | |
| $("restart").hidden = n === 0; | |
| } | |
| /* ---------- state ---------- */ | |
| function newState() { | |
| const buf = new Uint32Array(1); | |
| try { crypto.getRandomValues(buf); } catch (e) { buf[0] = Math.floor(Math.random() * 4294967296); } | |
| const seed = buf[0] >>> 0, rnd = mulberry32(seed); | |
| const assign = {}; | |
| D.trials.forEach((t) => { assign[t.id] = { v: (seed + (t.rot || 0)) % t.pairs.length, s: rnd() < 0.5 ? 0 : 1 }; }); | |
| const img = shuffle(modeTrials().filter((t) => t.kind === "image").map((t) => t.id), rnd); | |
| const vid = shuffle(modeTrials().filter((t) => t.kind === "video").map((t) => t.id), rnd); | |
| return { study: D.version, mode: MODE, seed, pid: seed.toString(36), lang, name: "", exp: "", order: img.concat(vid), nImg: img.length, | |
| assign, ans: {}, pos: 0, started: new Date().toISOString(), finished: false }; | |
| } | |
| const answered = (r) => !!r && !r.skip && r.q1 !== undefined && r.q2 !== undefined; | |
| const complete = (r) => !!r && (!!r.skip || answered(r)); | |
| function nDone() { return S ? S.order.filter((id) => complete(S.ans[id])).length : 0; } | |
| function firstIncomplete() { const i = S.order.findIndex((id) => !complete(S.ans[id])); return i < 0 ? null : i; } | |
| function code() { | |
| const chars = D.trials.map((t) => { | |
| const a = S.assign[t.id], r = S.ans[t.id]; | |
| return answered(r) ? ALPH[a.v * 8 + a.s * 4 + r.q1 * 2 + r.q2] : "~"; | |
| }).join(""); | |
| return `PBE1.${S.pid}.${S.exp === "" ? "x" : S.exp}.${chars}`; | |
| } | |
| function payload() { | |
| return { study: S.study, mode: MODE, pid: S.pid, seed: S.seed, lang: S.lang, name: S.name, exp: S.exp, started: S.started, | |
| updated: new Date().toISOString(), finished: S.finished, n: nDone(), order: S.order, | |
| assign: S.assign, answers: S.ans, code: code() }; | |
| } | |
| // Outside claude.ai (the public Hugging Face Space) the host page sets window.PBE_REMOTE to its save endpoint. | |
| const REMOTE = typeof window.PBE_REMOTE === "string" ? window.PBE_REMOTE : null; | |
| let rTimer = null, rBusy = false, rDirty = false; | |
| async function remoteSync() { | |
| if (!REMOTE || !S) return; | |
| if (rBusy) { rDirty = true; return; } | |
| rBusy = true; | |
| try { | |
| const r = await fetch(REMOTE, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload()) }); | |
| dbState = r.ok ? "ok" : "err"; | |
| } catch (e) { dbState = "err"; } | |
| rBusy = false; showSaved(); | |
| if (rDirty) { rDirty = false; remoteSync(); } | |
| } | |
| function save() { | |
| lsSet(); | |
| if (REMOTE) { clearTimeout(rTimer); rTimer = setTimeout(remoteSync, 800); } | |
| if (!dbDoc) { showSaved(); return; } | |
| clearTimeout(syncTimer); | |
| syncTimer = setTimeout(sync, 500); | |
| } | |
| async function sync() { | |
| if (!dbDoc) return; | |
| if (writing) { dirty = true; return; } | |
| writing = true; | |
| try { | |
| const body = { runs: { [runKey()]: payload() } }; // one doc per person, one entry per study version and mode | |
| if (docExists) { | |
| try { await dbDoc.update(body); } | |
| catch (e) { if (e && e.code === "invalid_argument") { docExists = false; await dbDoc.set(body); } else throw e; } | |
| } else await dbDoc.set(body); | |
| docExists = true; dbState = "ok"; | |
| } | |
| catch (e) { | |
| const c = e && e.code; | |
| if (c === "invalid_argument" || c === "not_granted" || c === "revoked" || c === "capability_disabled" || c === "capability_removed") { dbDoc = null; dbState = "off"; } | |
| else dbState = "err"; | |
| } | |
| writing = false; showSaved(); | |
| if (dirty && dbDoc) { dirty = false; sync(); } | |
| } | |
| function showSaved() { | |
| const el = $("savedMsg"); | |
| if (!S || !Object.keys(S.ans).length) { el.textContent = ""; return; } | |
| el.textContent = "· " + (dbState === "ok" ? tr("savedSync") : tr("savedLocal")); | |
| el.className = "saved" + (dbState === "ok" ? " ok" : ""); | |
| } | |
| /* ---------- screens ---------- */ | |
| function show(id) { ["welcome", "trial", "break", "done"].forEach((s) => { $(s).hidden = s !== id; }); window.scrollTo(0, 0); } | |
| function begin() { | |
| if (!S) S = newState(); | |
| S.lang = lang; S.name = $("pname").value.trim(); | |
| const e = document.querySelector('input[name="exp"]:checked'); S.exp = e ? e.value : S.exp; | |
| begun = true; | |
| save(); | |
| const k = firstIncomplete(); | |
| if (k === null) return finish(); | |
| if (k === S.nImg && k > 0) { S.pos = k; lsSet(); show("break"); return; } | |
| goto(k); | |
| } | |
| function goto(pos) { | |
| teardown(); | |
| S.pos = Math.max(0, Math.min(pos, S.order.length)); | |
| lsSet(); | |
| if (S.pos >= S.order.length) return finish(); | |
| show("trial"); | |
| mountTrial(S.order[S.pos]); | |
| } | |
| function teardown() { | |
| clearTimeout(advTimer); | |
| if (cur && cur.stop) cur.stop(); | |
| setZoom(false); | |
| cur = null; | |
| } | |
| async function finish() { | |
| teardown(); | |
| const n = nDone(), N = S.order.length; | |
| S.finished = n === N; | |
| lsSet(); | |
| show("done"); | |
| $("codeBox").textContent = code(); | |
| $("copyMsg").textContent = ""; | |
| $("reviewBtn").textContent = n === N ? tr("review") : tr("continue"); | |
| const st = $("doneStatus"); | |
| const paint = () => { | |
| st.textContent = (n === N ? "" : tr("savedPartial", { n, N }) + " ") + (dbState === "ok" ? tr("savedOk") : ""); | |
| st.className = "status" + (dbState === "ok" ? " ok" : ""); | |
| }; | |
| paint(); | |
| if (dbDoc) { clearTimeout(syncTimer); await sync(); paint(); } | |
| if (REMOTE) { clearTimeout(rTimer); await remoteSync(); paint(); } | |
| $("doneBody").textContent = dbState === "ok" ? tr("keepCode") : tr("sendCode"); | |
| } | |
| /* ---------- trial ---------- */ | |
| function mediaURL(file) { return blobs.get(file) || file; } | |
| function mountTrial(tid) { | |
| const t = T[tid], a = S.assign[tid], pair = t.pairs[a.v]; | |
| const L = a.s ? pair[1] : pair[0], R = a.s ? pair[0] : pair[1]; | |
| cur = { tid, t, L, R, shownAt: performance.now(), watched: t.kind === "image" || answered(S.ans[tid]), failed: false, stop: null }; | |
| const isVid = t.kind === "video"; | |
| const k = S.pos, nI = S.nImg; | |
| $("partName").textContent = isVid ? tr("partVid") : tr("partImg"); | |
| $("count").textContent = isVid ? `${k - nI + 1} / ${S.order.length - nI}` : `${k + 1} / ${nI}`; | |
| $("trackFill").style.width = (100 * k / S.order.length).toFixed(1) + "%"; | |
| renderTrialText(); | |
| $("stageMsg").textContent = ""; | |
| $("vctl").hidden = !isVid; | |
| const panels = [0, L, R]; | |
| if (isVid) mountVideo(t, panels); else mountImages(t, panels); | |
| const r = S.ans[tid]; | |
| activeQ = r && r.q1 !== undefined ? 2 : 1; | |
| paintAnswers(); | |
| prefetch(S.pos + 1); prefetch(S.pos + 2); | |
| } | |
| function renderTrialText() { | |
| if (!cur) return; | |
| const t = cur.t; | |
| $("instr").innerHTML = segs(t.instr); | |
| $("desc").innerHTML = t.desc && t.desc.length ? `${esc(tr("target"))} ${segs(t.desc)}` : ""; | |
| $("origLab").textContent = t.kind === "video" ? tr("origV") : tr("orig"); | |
| $("partName").textContent = t.kind === "video" ? tr("partVid") : tr("partImg"); | |
| $("zoomBtn").textContent = $("stage").classList.contains("zoomed") ? tr("shrink") : tr("enlarge"); | |
| paintAnswers(); | |
| } | |
| function mountImages(t, panels) { | |
| panels.forEach((p, i) => { | |
| const host = $("m" + i); | |
| host.innerHTML = ""; | |
| const d = document.createElement("div"); | |
| d.className = "media"; | |
| d.style.aspectRatio = `${t.w} / ${t.h}`; | |
| d.style.backgroundImage = `url("${t.file}")`; | |
| d.style.backgroundSize = `${t.n * 100}% 100%`; | |
| d.style.backgroundPosition = `${t.n > 1 ? (100 * p / (t.n - 1)).toFixed(4) : 0}% 0%`; | |
| d.setAttribute("role", "img"); | |
| d.setAttribute("aria-label", i === 0 ? tr("orig") : (i === 1 ? "A" : "B")); | |
| d.addEventListener("click", () => setZoom()); | |
| host.appendChild(d); | |
| }); | |
| const probe = new Image(); | |
| probe.onerror = () => { if (cur && cur.t === t) { $("stageMsg").textContent = tr("loadFail"); cur.failed = true; paintAnswers(); } }; | |
| probe.src = t.file; | |
| } | |
| function mountVideo(t, panels) { | |
| const v = document.createElement("video"); | |
| v.muted = true; v.loop = true; v.playsInline = true; v.preload = "auto"; | |
| v.setAttribute("playsinline", ""); v.setAttribute("muted", ""); | |
| v.style.cssText = "position:absolute;width:2px;height:2px;opacity:0;pointer-events:none;left:0;top:0"; | |
| document.body.appendChild(v); | |
| const ctxs = panels.map((p, i) => { | |
| const host = $("m" + i); host.innerHTML = ""; | |
| const c = document.createElement("canvas"); | |
| c.className = "media"; c.width = t.w; c.height = t.h; | |
| c.style.aspectRatio = `${t.w} / ${t.h}`; | |
| c.addEventListener("click", () => setZoom()); | |
| host.appendChild(c); | |
| const g = c.getContext("2d"); g.fillStyle = "#2c2c2c"; g.fillRect(0, 0, t.w, t.h); | |
| return g; | |
| }); | |
| const fps = t.fps, nf = t.frames, dur = nf / fps; | |
| const scrub = $("scrub"); scrub.max = String(nf - 1); scrub.value = "0"; | |
| let alive = true, raf = 0, lastNow = 0, playedMs = 0; | |
| const draw = () => { | |
| if (v.readyState >= 2) panels.forEach((p, i) => ctxs[i].drawImage(v, 0, p * (t.stride || t.h), t.w, t.h, 0, 0, t.w, t.h)); | |
| const f = Math.min(nf - 1, Math.floor(v.currentTime * fps + 1e-3)); | |
| if (document.activeElement !== scrub) scrub.value = String(f); | |
| $("frameNo").textContent = `${f + 1} / ${nf}`; | |
| // gate: one clip length of real playback (scrubbing does not count) | |
| const now = performance.now(); | |
| if (lastNow && !v.paused && v.readyState >= 3) playedMs += (now - lastNow) * v.playbackRate; | |
| lastNow = now; | |
| if (!cur.watched && playedMs >= dur * 1000 * 0.97) { cur.watched = true; paintAnswers(); } | |
| }; | |
| const tick = () => { if (!alive) return; draw(); raf = requestAnimationFrame(tick); }; | |
| const setPlayLabel = () => { $("playBtn").textContent = v.paused ? tr("paused") : tr("play"); }; | |
| v.addEventListener("loadeddata", () => { if (!alive) return; $("stageMsg").textContent = ""; draw(); }); | |
| v.addEventListener("playing", setPlayLabel); | |
| v.addEventListener("pause", setPlayLabel); | |
| v.addEventListener("error", () => { if (alive) { $("stageMsg").textContent = tr("loadFail"); cur.failed = true; paintAnswers(); } }); | |
| $("stageMsg").textContent = tr("loading"); | |
| v.src = mediaURL(t.file); | |
| const slow = $("slowBtn"); | |
| v.playbackRate = slow.getAttribute("aria-pressed") === "true" ? 0.5 : 1; | |
| v.play().catch(() => setPlayLabel()); | |
| raf = requestAnimationFrame(tick); | |
| cur.video = v; | |
| cur.toggle = () => { if (v.paused) v.play().catch(() => {}); else v.pause(); }; | |
| cur.stop = () => { alive = false; cancelAnimationFrame(raf); try { v.pause(); v.removeAttribute("src"); v.load(); } catch (e) {} v.remove(); }; | |
| setPlayLabel(); | |
| } | |
| function paintAnswers() { | |
| if (!cur) return; | |
| const r = S.ans[cur.tid] || {}, gated = !cur.watched; | |
| [["q1L", "q1", 0], ["q1R", "q1", 1], ["q2L", "q2", 0], ["q2R", "q2", 1]].forEach(([id, q, val]) => { | |
| const b = $(id); | |
| b.setAttribute("aria-pressed", String(r[q] === val)); | |
| b.disabled = gated; | |
| }); | |
| $("row1").classList.toggle("active", activeQ === 1); | |
| $("row2").classList.toggle("active", activeQ === 2); | |
| $("gateMsg").textContent = gated && !cur.failed ? tr("watch") : ""; | |
| const last = S.pos === S.order.length - 1; | |
| $("nextBtn").textContent = cur.failed && !answered(r) ? tr("skip") : (last ? tr("finish") : tr("next")); | |
| $("nextBtn").disabled = !(answered(r) || cur.failed); | |
| $("backBtn").disabled = S.pos === 0; | |
| } | |
| function answer(q, val) { | |
| if (!cur || !cur.watched) return; | |
| const prev = S.ans[cur.tid]; | |
| const r = Object.assign({}, prev || {}); | |
| const wasComplete = answered(prev); | |
| delete r.skip; | |
| r[q] = val; | |
| r.p = [cur.L, cur.R]; | |
| r.v = S.assign[cur.tid].v; r.s = S.assign[cur.tid].s; | |
| if (r.q1 !== undefined && r.q2 !== undefined && !wasComplete) { r.ms = Math.round(performance.now() - cur.shownAt); r.at = new Date().toISOString(); } | |
| S.ans[cur.tid] = r; | |
| activeQ = 2; | |
| paintAnswers(); | |
| save(); | |
| clearTimeout(advTimer); | |
| if (!wasComplete && r.q1 !== undefined && r.q2 !== undefined) advTimer = setTimeout(next, 420); | |
| } | |
| function next() { | |
| if (!cur) return; | |
| const r = S.ans[cur.tid]; | |
| if (!answered(r)) { | |
| if (!cur.failed) return; | |
| S.ans[cur.tid] = { skip: 1, at: new Date().toISOString() }; // media did not load: recorded as skipped | |
| save(); | |
| } | |
| const nxt = S.pos + 1; | |
| if (nxt === S.nImg && nxt < S.order.length) { teardown(); S.pos = nxt; lsSet(); show("break"); return; } | |
| goto(nxt); | |
| } | |
| function back() { if (S.pos > 0) goto(S.pos - 1); } | |
| function setZoom(on) { | |
| const st = $("stage"); | |
| const z = on === undefined ? !st.classList.contains("zoomed") : on; | |
| st.classList.toggle("zoomed", z); | |
| $("zoomNote").hidden = !z; | |
| $("zoomBtn").textContent = z ? tr("shrink") : tr("enlarge"); | |
| } | |
| function prefetch(pos) { | |
| if (!S || pos >= S.order.length) return; | |
| const t = T[S.order[pos]]; | |
| if (t.kind === "image") { const im = new Image(); im.src = t.file; return; } | |
| if (blobs.has(t.file)) return; | |
| blobs.set(t.file, t.file); | |
| fetch(t.file).then((r) => r.ok ? r.blob() : null).then((b) => { | |
| if (!b) { blobs.delete(t.file); return; } | |
| blobs.set(t.file, URL.createObjectURL(b)); | |
| if (blobs.size > 8) { | |
| const keep = new Set(S.order.slice(Math.max(0, S.pos - 1), S.pos + 4).map((id) => T[id].file)); | |
| for (const [f, u] of blobs) if (!keep.has(f) && blobs.size > 8) { if (u.startsWith("blob:")) URL.revokeObjectURL(u); blobs.delete(f); } | |
| } | |
| }).catch(() => blobs.delete(t.file)); | |
| } | |
| /* ---------- wiring ---------- */ | |
| document.querySelectorAll("[data-lang]").forEach((b) => b.addEventListener("click", () => { | |
| lang = b.dataset.lang; if (S) { S.lang = lang; lsSet(); } | |
| try { localStorage.setItem("pbe-lang", lang); } catch (e) {} | |
| applyLang(); | |
| })); | |
| $("start").addEventListener("click", begin); | |
| $("restart").addEventListener("click", () => { | |
| lsDel(); S = null; setStartLabel(); | |
| if (dbDoc && docExists && D) dbDoc.update({ runs: { [runKey()]: null } }).catch(() => {}); | |
| }); | |
| $("q1L").addEventListener("click", () => answer("q1", 0)); | |
| $("q1R").addEventListener("click", () => answer("q1", 1)); | |
| $("q2L").addEventListener("click", () => answer("q2", 0)); | |
| $("q2R").addEventListener("click", () => answer("q2", 1)); | |
| $("row1").addEventListener("click", (e) => { if (e.target.closest("button")) return; activeQ = 1; paintAnswers(); }); | |
| $("row2").addEventListener("click", (e) => { if (e.target.closest("button")) return; activeQ = 2; paintAnswers(); }); | |
| $("nextBtn").addEventListener("click", next); | |
| $("backBtn").addEventListener("click", back); | |
| $("stopBtn").addEventListener("click", finish); | |
| $("zoomBtn").addEventListener("click", () => setZoom()); | |
| $("toVideos").addEventListener("click", () => goto(S.pos)); | |
| $("reviewBtn").addEventListener("click", () => { | |
| const k = firstIncomplete(); | |
| if (k !== null && k === S.nImg && k > 0) { S.pos = k; lsSet(); show("break"); return; } | |
| goto(k === null ? S.order.length - 1 : k); | |
| }); | |
| $("playBtn").addEventListener("click", () => cur && cur.toggle && cur.toggle()); | |
| $("slowBtn").addEventListener("click", () => { | |
| const b = $("slowBtn"), on = b.getAttribute("aria-pressed") !== "true"; | |
| b.setAttribute("aria-pressed", String(on)); | |
| if (cur && cur.video) cur.video.playbackRate = on ? 0.5 : 1; | |
| }); | |
| $("scrub").addEventListener("change", () => $("scrub").blur()); | |
| $("scrub").addEventListener("input", () => { | |
| if (!cur || !cur.video) return; | |
| const v = cur.video; v.pause(); | |
| v.currentTime = (Number($("scrub").value) + 0.5) / cur.t.fps; | |
| }); | |
| $("copyBtn").addEventListener("click", () => { | |
| const txt = $("codeBox").textContent; | |
| const fail = () => { | |
| const rg = document.createRange(); rg.selectNodeContents($("codeBox")); | |
| const sel = getSelection(); sel.removeAllRanges(); sel.addRange(rg); | |
| $("copyMsg").textContent = tr("copyFail"); | |
| }; | |
| try { navigator.clipboard.writeText(txt).then(() => { $("copyMsg").textContent = tr("copied"); }, fail); } catch (e) { fail(); } | |
| }); | |
| document.addEventListener("keydown", (e) => { | |
| if (e.target.matches && e.target.matches('input[type="text"], textarea, select')) return; | |
| if (e.metaKey || e.ctrlKey || e.altKey) return; | |
| if ($("trial").hidden) { if (e.key === "Enter" && !$("break").hidden) { e.preventDefault(); goto(S.pos); } return; } | |
| const k = e.key; | |
| if (k === "a" || k === "A" || k === "ArrowLeft") { e.preventDefault(); answer(activeQ === 1 ? "q1" : "q2", 0); } | |
| else if (k === "b" || k === "B" || k === "ArrowRight") { e.preventDefault(); answer(activeQ === 1 ? "q1" : "q2", 1); } | |
| else if (k === "ArrowUp") { activeQ = 1; paintAnswers(); } | |
| else if (k === "ArrowDown") { activeQ = 2; paintAnswers(); } | |
| else if (k === "Enter") { e.preventDefault(); next(); } | |
| else if (k === "Backspace") { e.preventDefault(); back(); } | |
| else if (k === " ") { if (cur && cur.toggle) { e.preventDefault(); cur.toggle(); } } | |
| else if (k === "z" || k === "Z") setZoom(); | |
| else if (k === "Escape") setZoom(false); | |
| }); | |
| /* ---------- organizer (owner only) ---------- */ | |
| async function organizer(db) { | |
| $("organizer").hidden = false; | |
| const refresh = async () => { | |
| try { | |
| const [rs, cs] = await Promise.all([db.collection("responses").get(), db.collection("codes").get()]); | |
| let ans = 0, fin = 0; | |
| let runs = 0; | |
| rs.docs.forEach((d) => { | |
| const x = d.data() || {}; | |
| const list = x.runs ? Object.values(x.runs).filter(Boolean) : [x]; | |
| list.forEach((r) => { runs++; ans += r.n || 0; if (r.finished) fin++; }); | |
| }); | |
| $("orgStats").textContent = `Saved here: ${rs.size} people, ${runs} runs (${fin} finished), ${ans} answered pairs · codes added: ${cs.size}`; | |
| } catch (e) { $("orgStats").textContent = "Could not read the saved answers right now."; } | |
| }; | |
| $("addCodes").addEventListener("click", async () => { | |
| const N = D ? D.trials.length : 0; | |
| const lines = $("codesIn").value.split(/\s+/).map((s) => s.trim()) | |
| .filter((s) => /^PBE1\.[0-9a-z]+\.[0-2x]\.[A-Za-n~]+$/.test(s) && s.split(".")[3].length === N); | |
| if (!lines.length) { $("codeMsg").textContent = `No valid code found (codes start with PBE1. and have ${N} answer characters).`; return; } | |
| let ok = 0; | |
| for (const c of lines) { | |
| const pid = c.split(".")[1]; | |
| try { await db.collection("codes").doc(pid).set({ code: c, added: new Date().toISOString() }); ok++; } catch (e) {} | |
| } | |
| $("codeMsg").textContent = `Added ${ok} of ${lines.length}.`; | |
| $("codesIn").value = ""; | |
| refresh(); | |
| }); | |
| refresh(); | |
| } | |
| async function connect() { | |
| if (!window.claude || !window.claude.use) return; | |
| let user = null, db = null; | |
| try { [user, db] = await Promise.all([window.claude.use("user"), window.claude.use("db")]); } catch (e) { return; } | |
| if (!user || !db) return; | |
| let uid = null; | |
| try { uid = await user.id(); } catch (e) {} | |
| try { if (await user.isOwner()) organizer(db); } catch (e) {} | |
| if (!uid) return; | |
| const ref = db.doc("responses/" + uid); | |
| try { | |
| const snap = await ref.get(); | |
| const doc = snap.exists ? snap.data() : null; | |
| let x = doc && doc.runs ? doc.runs[runKey()] : null; | |
| if (!x && doc && MODE === "full" && doc.study === (D && D.version)) x = doc; // first-build docs kept one flat run | |
| const nx = x ? Object.values(x.answers || {}).filter(complete).length : 0; | |
| if (x && D && !begun && x.study === D.version && x.order && x.order.length === modeTrials().length && (!S || nx > nDone())) { | |
| S = { study: x.study, mode: MODE, seed: x.seed, pid: x.pid, lang: x.lang || lang, name: x.name || "", exp: x.exp || "", order: x.order, | |
| nImg: x.order.filter((id) => T[id] && T[id].kind === "image").length, assign: x.assign, ans: JSON.parse(JSON.stringify(x.answers || {})), | |
| pos: 0, started: x.started, finished: !!x.finished }; | |
| S.pos = firstIncomplete() ?? S.order.length; | |
| $("pname").value = S.name; | |
| if (S.exp !== "") { const el = $("exp" + S.exp); if (el) el.checked = true; } | |
| lsSet(); setStartLabel(); | |
| } | |
| dbDoc = ref; docExists = snap.exists; dbState = snap.exists ? "ok" : "none"; | |
| if (S && Object.keys(S.ans).length && begun) sync(); | |
| } catch (e) { dbDoc = null; } | |
| } | |
| /* ---------- boot ---------- */ | |
| (async function boot() { | |
| try { const l = localStorage.getItem("pbe-lang"); if (l === "zh" || l === "en") lang = l; } catch (e) {} | |
| applyLang(); | |
| $("loadmsg").textContent = tr("loadingData"); | |
| try { | |
| const r = await fetch("data.json"); | |
| D = await r.json(); | |
| } catch (e) { $("loadmsg").textContent = tr("dataFail"); return; } | |
| D.trials.forEach((t, k) => { T[t.id] = t; IDX[t.id] = k; }); | |
| const saved = lsGet(); | |
| if (saved && saved.study === D.version && saved.order && saved.order.length === modeTrials().length) { | |
| S = saved; if (S.lang) lang = S.lang; | |
| $("pname").value = S.name || ""; | |
| if (S.exp !== "") { const r = $("exp" + S.exp); if (r) r.checked = true; } | |
| } | |
| applyLang(); | |
| const ready = () => { $("loadmsg").textContent = ""; $("start").disabled = false; }; | |
| const timer = setTimeout(ready, 8000); | |
| connect().finally(() => { clearTimeout(timer); ready(); }); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |