diff --git a/app.js b/app.js index a42a1c8..f582f01 100644 --- a/app.js +++ b/app.js @@ -1,3 +1,4 @@ + const textInput = document.getElementById("textInput"); const wordEl = document.getElementById("word"); const focusBox = document.getElementById("focus-box"); @@ -7,23 +8,46 @@ const playPauseBtn = document.getElementById("playPause"); const stopBtn = document.getElementById("stop"); const pivotToggle = document.getElementById("pivotToggle"); +const chunkToggle = document.getElementById("chunkToggle"); +const biasLabel = document.getElementById("biasLabel"); +const biasButtons = document.querySelectorAll(".pivot-bias button"); + const wpmSlider = document.getElementById("wpm"); const wpmVal = document.getElementById("wpmVal"); -let words = []; -let positions = []; + +let units = []; let index = 0; let running = false; let timer = null; + +let pivotBias = 0; // -1, 0, +1 +let chunkEnabled = true; + + wpmVal.textContent = wpmSlider.value; -wpmSlider.oninput = () => wpmVal.textContent = wpmSlider.value; +wpmSlider.oninput = () => (wpmVal.textContent = wpmSlider.value); document.addEventListener("keydown", e => { - if (e.code !== "Space") return; - if (document.activeElement === textInput && !textInput.readOnly) return; - e.preventDefault(); - togglePlayPause(); + // Space = play / pause + if (e.code === "Space") { + if (document.activeElement === textInput && !textInput.readOnly) return; + e.preventDefault(); + togglePlayPause(); + } + + + if (e.code === "ArrowLeft") { + e.preventDefault(); + if (running) togglePlayPause(); + step(-1); + } + if (e.code === "ArrowRight") { + e.preventDefault(); + if (running) togglePlayPause(); + step(1); + } }); playPauseBtn.onclick = togglePlayPause; @@ -40,63 +64,151 @@ pivotToggle.onchange = () => { pivotLine.classList.toggle("hidden", !pivotToggle.checked); }; + +chunkToggle.onchange = () => { + chunkEnabled = chunkToggle.checked; + index = 0; + parseText(); + if (!running) draw(); +}; + + +biasButtons.forEach(btn => { + btn.onclick = () => { + pivotBias += Number(btn.dataset.bias); + pivotBias = Math.max(-1, Math.min(1, pivotBias)); + + biasLabel.textContent = + pivotBias === 0 ? "Default" : pivotBias < 0 ? "Earlier" : "Later"; + + if (!running) draw(); + }; +}); + + +const SEED_CHUNKS = new Set([ + "in the", "of the", "to the", "for the", "on the", + "as well", "there is", "there are", + "for example", "such as", "because of", "in order", + "according to", "at least", "going to", "at one", "one of", + "part of", "due to", "in fact", "in case", "by the", + "in which", "so that", "as soon", "be able", + "the end", "the same", "the way", "some of", + "a lot", "lots of", "kind of", "sort of", "a little", + "as if", "as to", "as for", "as to", "in time", + "in place", "on time", "on place", "by far", + "by then", "up to", "out of", "due to", "ahead of", "close to", "was a", "is a", + "it is", "it was", "that is", "that was", "there was", "he was", "she was" +]); + +function isChunkCandidate(a, b) { + if (!chunkEnabled) return false; + if (/[.!?,;:]$/.test(a)) return false; + if (a.length > 8 || b.length > 8) return false; + return SEED_CHUNKS.has((a + " " + b).toLowerCase()); +} + + function parseText() { - words = []; - positions = []; + units = []; const text = textInput.value; const regex = /\S+/g; + const tokens = []; let m; while ((m = regex.exec(text)) !== null) { - words.push(m[0]); - positions.push([m.index, m.index + m[0].length]); + tokens.push({ + word: m[0], + start: m.index, + end: m.index + m[0].length + }); + } + + let i = 0; + while (i < tokens.length) { + if ( + i + 1 < tokens.length && + isChunkCandidate(tokens[i].word, tokens[i + 1].word) + ) { + units.push(makeUnit(tokens.slice(i, i + 2))); + i += 2; + } else { + units.push(makeUnit([tokens[i]])); + i += 1; + } } } -function pivotIndex(word) { - if (word.length <= 1) return 0; - if (word.length <= 5) return 1; - if (word.length <= 9) return 2; - return 3; +function makeUnit(tokens) { + const text = tokens.map(t => t.word).join(" "); + const span = [tokens[0].start, tokens[tokens.length - 1].end]; + + let pivotWordIndex = 0; + let maxLen = 0; + + tokens.forEach((t, i) => { + const clean = t.word.replace(/\W/g, ""); + if (clean.length > maxLen) { + maxLen = clean.length; + pivotWordIndex = i; + } + }); + + return { text, span, pivotWordIndex }; } -function highlight(i) { - const p = positions[i]; - if (!p) return; - textInput.focus(); - textInput.setSelectionRange(p[0], p[1]); - scrollHighlightIntoView(); + +function basePivotIndex(word) { + const len = word.length; + + if (len <= 4) return 1; + if (len <= 7) return 2; + if (len <= 10) return 3; + + + return Math.min(len - 2, Math.floor(len * 0.35)); } -function scrollHighlightIntoView() { - const ta = textInput; - const before = ta.value.slice(0, ta.selectionStart); - const lines = before.split("\n"); - const line = lines.length - 1; - const lineHeight = 20; - const center = ta.clientHeight / 2; - ta.scrollTop = line * lineHeight - center; + +function highlight(unit) { + textInput.focus(); + textInput.setSelectionRange(unit.span[0], unit.span[1]); } function draw() { - if (!words[index]) return; + const unit = units[index]; + if (!unit) return; - highlight(index); + highlight(unit); - const raw = words[index].replace(/^[\W_]+|[\W_]+$/g, ""); - const p = pivotIndex(raw); - const pre = raw.slice(0, p); - const mid = raw[p] || ""; - const post = raw.slice(p + 1); + const words = unit.text.split(" "); + const pivotWord = words[unit.pivotWordIndex]; + const clean = pivotWord.replace(/\W/g, ""); - wordEl.innerHTML = - `${pre}${mid}${post}`; + let p = basePivotIndex(clean) + pivotBias; + p = Math.max(0, Math.min(clean.length - 1, p)); + + let before = words.slice(0, unit.pivotWordIndex).join(" "); + if (before) before += " "; - wordEl.style.fontSize = "96px"; + const pre = clean.slice(0, p); + const mid = clean[p] || ""; + const post = clean.slice(p + 1); + let after = words.slice(unit.pivotWordIndex + 1).join(" "); + if (after) after = " " + after; + + wordEl.innerHTML = + `${before}${pre}` + + `${mid}` + + `${post}${after}`; + + // wordEl.style.fontSize = "90px"; + wordEl.style.fontSize = window.innerWidth <= 768 ? "48px" : "90px"; requestAnimationFrame(alignAndScale); } + function alignAndScale() { const mid = wordEl.querySelector(".pivot"); @@ -104,20 +216,52 @@ function alignAndScale() { const boxWidth = focusBox.clientWidth; const pivotX = boxWidth / 2; - const midCenter = mid.offsetLeft + mid.offsetWidth / 2; + + + wordEl.style.left = "0px"; + + + wordEl.offsetWidth; + + const midCenter = + mid.offsetLeft + mid.offsetWidth / 2; + wordEl.style.left = (pivotX - midCenter) + "px"; - let size = 96; - while (wordEl.scrollWidth > boxWidth - 40 && size > 40) { + let size = parseFloat(getComputedStyle(wordEl).fontSize); + const minSize = window.innerWidth <= 768 ? 28 : 40; + const padding = window.innerWidth <= 768 ? 14 : 40; + + while ( + wordEl.scrollWidth > boxWidth - padding && + size > minSize + ) { size -= 2; wordEl.style.fontSize = size + "px"; + wordEl.offsetWidth; // force reflow } } + + +function pacingMultiplier(text) { + let m = 1; + + if (/[.!?]$/.test(text)) m *= 2.4; + else if (/[,:;]$/.test(text)) m *= 1.6; + + const letters = text.replace(/\W/g, "").length; + if (letters >= 10) m *= 1.35; + else if (letters >= 7) m *= 1.2; + + return m; +} + + function togglePlayPause() { if (!running) { - if (!words.length || index === 0) parseText(); + if (!units.length || index === 0) parseText(); running = true; textInput.readOnly = true; playPauseBtn.textContent = "⏸"; @@ -129,15 +273,10 @@ function togglePlayPause() { } } -function punctuationDelay(word) { - if (/[.!?]$/.test(word)) return 2.4; - if (/[,:;]$/.test(word)) return 1.6; - return 1; -} - function loop() { if (!running) return; - if (index >= words.length) { + + if (index >= units.length) { running = false; textInput.readOnly = false; playPauseBtn.textContent = "▶"; @@ -147,8 +286,22 @@ function loop() { draw(); let delay = 60000 / wpmSlider.value; - delay *= punctuationDelay(words[index]); + delay *= pacingMultiplier(units[index].text); index++; timer = setTimeout(loop, delay); } + + + +function step(dir) { + if (!units.length) return; + index = Math.max(0, Math.min(units.length - 1, index + dir)); + draw(); +} +// mobile +focusBox.addEventListener("click", () => { + if (document.activeElement === textInput && !textInput.readOnly) return; + togglePlayPause(); +}); + diff --git a/index.html b/index.html index c5ebf06..20c9488 100644 --- a/index.html +++ b/index.html @@ -1,19 +1,28 @@ - + +
- -