const canvas = document.getElementById("tw-canvas"); const ctx = canvas.getContext("2d"); const typewriterFont = '16px "Courier Prime", monospace'; const handwritingFont = '24px "Caveat", cursive'; const color = "#111"; let lineHeightPx = 28; const paddingY = 4; const extraSpace = 40; const finalText = "If you corresponded with Bukowski, there's a good chance I might have copies of your letters to him. Drop me a line!"; const underlineWord = "Bukowski"; const typewriterSteps = [ { type: "text", value: "If you corresponded with " }, { type: "text", value: "Bukowksi" }, { type: "pause", time: 450 }, { type: "backspace", chars: 3 }, { type: "text", value: "ski" }, { type: "text", value: ", there's a good chance I might have copies of your " }, { type: "text", value: "lettres" }, { type: "pause", time: 450 }, { type: "backspace", chars: 3 }, { type: "text", value: "ers" }, { type: "text", value: " to him. Drop me a line!" } ]; const handwritingSteps = [ { type: "text", value: "If you corresponded with Bukowski, " }, { type: "text", value: "theirs" }, { type: "pause", time: 500 }, { type: "backspace", chars: 6 }, { type: "text", value: "there's a good chance I " }, { type: "text", value: "migth" }, { type: "pause", time: 400 }, { type: "backspace", chars: 2 }, { type: "text", value: "ht have copies of your letters to him. Drop me a line!" } ]; let currentFont = typewriterFont; let currentSteps = typewriterSteps; let stepIndex = 0; let charIndex = 0; let typed = ""; let underlineProgress = 0; let underlineTimer = null; let lastWidth = 0; let lastHeight = 0; let eraseActive = false; let handwritingMode = false; let charOffsets = []; function wrapText(text) { ctx.font = currentFont; const maxWidth = canvas.parentElement.clientWidth; const tokens = text.match(/\S+\s*/g) || [""]; const lines = []; let current = ""; let currentStart = 0; let index = 0; for (const token of tokens) { const tokenStart = index; const test = current + token; if (!current || ctx.measureText(test).width <= maxWidth) { current = test; } else { lines.push({ text: current, startIndex: currentStart }); current = token; currentStart = tokenStart; } index += token.length; } if (current || !lines.length) lines.push({ text: current, startIndex: currentStart }); return lines; } function resizeCanvasForText(text) { const w = canvas.parentElement.clientWidth; const lines = wrapText(text); const h = Math.max(lineHeightPx + paddingY * 2 + extraSpace, lines.length * lineHeightPx + paddingY * 2 + extraSpace); const dpr = window.devicePixelRatio || 1; if (Math.floor(w * dpr) !== lastWidth || Math.floor(h * dpr) !== lastHeight) { canvas.width = Math.floor(w * dpr); canvas.height = Math.floor(h * dpr); canvas.style.height = h + "px"; lastWidth = canvas.width; lastHeight = canvas.height; } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return lines; } function render(text) { if (eraseActive) return; const lines = resizeCanvasForText(text); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.font = currentFont; ctx.fillStyle = color; ctx.textBaseline = "top"; let globalCharCounter = 0; lines.forEach((line, i) => { let xPos = 0; const yBase = i * lineHeightPx; if (!handwritingMode) { ctx.fillText(line.text, 0, yBase); } else { for (let char of line.text) { if (!charOffsets[globalCharCounter]) { charOffsets[globalCharCounter] = { y: (Math.random() - 0.5) * 3, rot: (Math.random() - 0.5) * 0.04, kern: (Math.random() - 0.5) * 1.5 }; } const offset = charOffsets[globalCharCounter]; ctx.save(); ctx.translate(xPos, yBase + offset.y); ctx.rotate(offset.rot); ctx.fillText(char, 0, 0); ctx.restore(); xPos += ctx.measureText(char).width + offset.kern; globalCharCounter++; } } }); if (text === finalText) { drawUnderline(text, lines); } } function drawUnderline(text, lines) { const wordIndex = text.indexOf(underlineWord); if (wordIndex === -1) return; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (wordIndex >= line.startIndex && wordIndex < line.startIndex + line.text.length) { const before = line.text.slice(0, wordIndex - line.startIndex); const targetX = ctx.measureText(before).width; const targetY = i * lineHeightPx + (handwritingMode ? 26 : 18); const wordWidth = ctx.measureText(underlineWord.slice(0, underlineProgress)).width; ctx.strokeStyle = color; ctx.lineWidth = 1.8; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(targetX, targetY); for (let j = 1; j <= 8; j++) ctx.lineTo(targetX + (wordWidth/8) * j, targetY + (Math.random() - 0.5) * 3); ctx.stroke(); } } } function startManualEraser() { eraseActive = true; const dpr = window.devicePixelRatio || 1; const w = canvas.width / dpr; const h = canvas.height / dpr; let totalStrokes = 0; const maxStrokes = 180; function scrub() { if (totalStrokes < maxStrokes) { ctx.globalCompositeOperation = 'destination-out'; const targetX = Math.random() * w; const targetY = Math.random() * h; const scrubDensity = 4 + Math.random() * 4; for(let i=0; i < scrubDensity; i++) { ctx.beginPath(); const jitterX = (Math.random() - 0.5) * 40; const jitterY = (Math.random() - 0.5) * 20; ctx.arc(targetX + jitterX, targetY + jitterY, 15 + Math.random() * 10, 0, Math.PI * 2); ctx.fill(); } totalStrokes++; const isPausing = Math.random() > 0.92; const delay = isPausing ? (150 + Math.random() * 300) : 16; setTimeout(scrub, delay); } else { ctx.globalCompositeOperation = 'source-over'; ctx.clearRect(0, 0, canvas.width, canvas.height); eraseActive = false; if (!handwritingMode) { setTimeout(() => { handwritingMode = true; currentFont = handwritingFont; lineHeightPx = 34; currentSteps = handwritingSteps; resetCycle(); runStep(); }, 5000); } else { setTimeout(() => { handwritingMode = false; currentFont = typewriterFont; lineHeightPx = 28; currentSteps = typewriterSteps; resetCycle(); runStep(); }, 2000); } } } scrub(); } function resetCycle() { stepIndex = 0; charIndex = 0; typed = ""; underlineProgress = 0; charOffsets = []; } function startUnderlineAnimation() { clearInterval(underlineTimer); underlineProgress = 0; underlineTimer = setInterval(() => { underlineProgress++; render(typed); if (underlineProgress >= underlineWord.length) { clearInterval(underlineTimer); setTimeout(startManualEraser, 5000); } }, 120); } function runStep() { if (stepIndex >= currentSteps.length) { typed = finalText; render(typed); startUnderlineAnimation(); return; } const step = currentSteps[stepIndex]; if (step.type === "text") { if (charIndex < step.value.length) { typed += step.value.charAt(charIndex++); render(typed); const speed = handwritingMode ? (65 + Math.random() * 45) : (50 + Math.random() * 80); setTimeout(runStep, speed); } else { stepIndex++; charIndex = 0; runStep(); } } else if (step.type === "pause") { setTimeout(() => { stepIndex++; runStep(); }, step.time); } else if (step.type === "backspace") { let remaining = step.chars; function back() { if (remaining > 0) { typed = typed.slice(0, -1); remaining--; render(typed); setTimeout(back, handwritingMode ? 100 : 80); } else { stepIndex++; charIndex = 0; runStep(); } } back(); } } document.fonts.ready.then(() => { render(""); setTimeout(runStep, 500); });