|
58 | 58 | @keyframes slide { from { transform:translateX(-105%); } to { transform:translateX(480%); } } |
59 | 59 | @keyframes blink { 0%,100% { opacity:1; } 50% { opacity:.45; } } |
60 | 60 | #transport button:disabled:hover { color:var(--dim); background:var(--key); } |
61 | | - #strip { display:flex; gap:3px; flex:1 1 220px; min-height:26px; } |
| 61 | + #strip { display:flex; gap:3px; flex:1 1 220px; min-height:26px; touch-action:none; } |
62 | 62 | #strip .seg { position:relative; flex:1 1 0; min-width:6px; border-radius:4px; |
63 | 63 | background:var(--key); cursor:pointer; overflow:hidden; transition:background .15s; } |
64 | 64 | #strip .seg:hover { background:var(--keyOn); } |
|
83 | 83 | <b>→</b>/<b>←</b> hunk · <b>x</b>/<b>X</b> speed · <b>m</b> sound · <b>v</b> voice · <b>f</b> full · <b>a</b> autoplay<br> |
84 | 84 | <b>wheel</b> or <b>j</b>/<b>k</b> scroll · <b>ctrl-wheel</b> or <b>+</b>/<b>−</b> zoom · |
85 | 85 | <b>shift-wheel</b> sideways · <b>0</b> refit and follow · <b>c</b> context · <b>l</b> overlay · <b>g</b>/<b>G</b> front/back · |
86 | | - <b>s</b> side by side · |
| 86 | + <b>s</b> side by side · <b>drag the strip</b> to scrub · |
87 | 87 | <b>t</b> theme · <b>drag</b> orbit · <b>?</b> hide this<br> |
88 | 88 | every change is typed out: <span class="del">removals erase</span>, |
89 | 89 | <span class="add">additions type in</span> |
|
1152 | 1152 | }); |
1153 | 1153 | } |
1154 | 1154 |
|
1155 | | -const narrate = () => |
| 1155 | +const narrate = () => { |
| 1156 | + if (scrub) return; // a drag crosses many hunks; the one it lands on speaks |
1156 | 1157 | say([ |
1157 | 1158 | hunk.group && { text: hunk.group, tone: TONE.group }, |
1158 | 1159 | { text: whatChanged(), tone: TONE.what }, |
1159 | 1160 | hunk.note && { text: hunk.note, tone: TONE.why }, |
1160 | 1161 | ]); |
| 1162 | +}; |
1161 | 1163 |
|
1162 | 1164 | function syncVoiceBtn() { |
1163 | 1165 | button("voice").textContent = voiceOn ? "voice" : "silent"; |
|
1229 | 1231 | const edge = document.createElement("i"); |
1230 | 1232 | edge.className = entry.additions >= entry.deletions ? "add" : "del"; |
1231 | 1233 | seg.append(bar, edge); |
1232 | | - seg.onclick = () => goto(i); |
1233 | 1234 | strip.appendChild(seg); |
1234 | 1235 | }); |
1235 | 1236 | } |
1236 | 1237 |
|
| 1238 | +/* ---------- scrubbing ---------- |
| 1239 | + The strip is one timeline for the whole review: the pointer lands on a |
| 1240 | + segment and a fraction inside it, so any keystroke of any hunk is one gesture |
| 1241 | + away. Playing and speaking wait for the pointer to lift, and the keystroke |
| 1242 | + sound is marked as already heard, or a forward drag would fire every click |
| 1243 | + between here and there at once. */ |
| 1244 | +let scrub = null; |
| 1245 | + |
| 1246 | +const boxOf = (seg) => seg.getBoundingClientRect(); |
| 1247 | + |
| 1248 | +function pointAt(x) { |
| 1249 | + const segs = [...strip.children]; |
| 1250 | + const found = segs.findIndex((seg) => x < boxOf(seg).right); |
| 1251 | + const index = found < 0 ? segs.length - 1 : found; |
| 1252 | + const box = boxOf(segs[index]); |
| 1253 | + return { index, fraction: Math.min(1, Math.max(0, (x - box.left) / box.width)) }; |
| 1254 | +} |
| 1255 | + |
| 1256 | +function scrubTo(x) { |
| 1257 | + const { index, fraction } = pointAt(x); |
| 1258 | + if (index !== at) goto(index); |
| 1259 | + clock = fraction * timeline.total; |
| 1260 | + heard = clock; |
| 1261 | +} |
| 1262 | + |
| 1263 | +strip.addEventListener("pointerdown", (e) => { |
| 1264 | + strip.setPointerCapture(e.pointerId); |
| 1265 | + scrub = { playing: speed > 0 }; |
| 1266 | + setSpeed(0); |
| 1267 | + hush(); |
| 1268 | + scrubTo(e.clientX); |
| 1269 | +}); |
| 1270 | + |
| 1271 | +strip.addEventListener("pointermove", (e) => { |
| 1272 | + if (scrub) scrubTo(e.clientX); |
| 1273 | +}); |
| 1274 | + |
| 1275 | +const endScrub = () => { |
| 1276 | + if (!scrub) return; |
| 1277 | + const { playing } = scrub; |
| 1278 | + scrub = null; |
| 1279 | + setSpeed(playing ? 1 : 0); |
| 1280 | + narrate(); |
| 1281 | +}; |
| 1282 | + |
| 1283 | +strip.addEventListener("pointerup", endScrub); |
| 1284 | +strip.addEventListener("pointercancel", endScrub); |
| 1285 | + |
1237 | 1286 | function resize() { |
1238 | 1287 | camera.aspect = innerWidth / innerHeight; |
1239 | 1288 | camera.updateProjectionMatrix(); |
|
0 commit comments