mirror of
https://github.com/kennethreitz/rhymepad.org.git
synced 2026-07-21 22:09:30 +00:00
1616 lines
67 KiB
JavaScript
1616 lines
67 KiB
JavaScript
import { stopRap } from './performance.js';
|
||
|
||
/* ============================================================
|
||
ANALYSIS — real phonetic rhyme detection lives in the Python
|
||
backend (CMU pronouncing dictionary). We POST the draft and
|
||
get back colored token spans:
|
||
end rhymes -> strong tint
|
||
internal rhymes -> soft tint
|
||
gray tint -> an ending no rhyme answers yet
|
||
============================================================ */
|
||
const editor = document.getElementById('editor');
|
||
const highlight = document.getElementById('highlight');
|
||
const stresslayer = document.getElementById('stresslayer');
|
||
const gutter = document.getElementById('gutter');
|
||
|
||
const stressToggle = document.getElementById('stressToggle');
|
||
stressToggle.checked = false;
|
||
stressToggle.addEventListener('change', ()=>{
|
||
document.querySelector('.editor-shell').classList.toggle('rhythm', stressToggle.checked);
|
||
render();
|
||
});
|
||
const allitToggle = document.getElementById('allitToggle');
|
||
allitToggle.checked = false;
|
||
allitToggle.addEventListener('change', render);
|
||
const rhymeToggle = document.getElementById('rhymeToggle');
|
||
rhymeToggle.checked = true;
|
||
rhymeToggle.addEventListener('change', ()=>{
|
||
if(!rhymeToggle.checked && ghost){ ghost = null; closeGhostMenu(); }
|
||
render();
|
||
});
|
||
const schemeReadout = document.getElementById('schemeReadout');
|
||
const COLORS = 16; // matches --r0..--r15 above and COLORS in app.py
|
||
|
||
function esc(s){ return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
function debounce(fn, ms){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; }
|
||
|
||
let analysis = null; // last server response
|
||
let karaokeSpan = null; // {l, s, e} — the word being rapped right now
|
||
function setKaraoke(v){ karaokeSpan = v; }
|
||
let backendOk = true;
|
||
let focusAllit = null; // alliteration group under the caret
|
||
let activeFam = null; // the rhyme family currently emphasized (hover or caret)
|
||
let hoverGid = null; // family under the mouse (hover preview)
|
||
function gidAtPoint(x, y){
|
||
let off = null;
|
||
if(document.caretPositionFromPoint){
|
||
const c = document.caretPositionFromPoint(x, y);
|
||
if(c && c.offsetNode) off = c.offset;
|
||
}else if(document.caretRangeFromPoint){
|
||
const r = document.caretRangeFromPoint(x, y);
|
||
if(r) off = r.startOffset;
|
||
}
|
||
if(off == null) return null;
|
||
const before = editor.value.slice(0, off);
|
||
const ln = before.split('\n').length - 1;
|
||
const col = off - (before.lastIndexOf('\n') + 1);
|
||
let best = null;
|
||
(analysis ? analysis.tokens : []).forEach(t=>{
|
||
if(t.l === ln && !t.ph && t.s <= col && col < t.e){
|
||
if(!best || (t.e - t.s) < (best.e - best.s)) best = t;
|
||
}
|
||
});
|
||
return best ? best.g : null;
|
||
}
|
||
|
||
function caretGid(){
|
||
if(!analysis || editor.selectionStart !== editor.selectionEnd) return null;
|
||
const pos = editor.selectionStart;
|
||
const before = editor.value.slice(0, pos);
|
||
const ln = before.split('\n').length - 1;
|
||
const col = pos - (before.lastIndexOf('\n') + 1);
|
||
const line = editor.value.split('\n')[ln];
|
||
if(!analysis.lines || analysis.lines[ln] !== line) return null;
|
||
let best = null;
|
||
analysis.tokens.forEach(t=>{
|
||
if(t.l === ln && t.s <= col && col < t.e){
|
||
if(!best || (t.e - t.s) < (best.e - best.s)) best = t;
|
||
}
|
||
});
|
||
return best ? best.g : null;
|
||
}
|
||
|
||
function caretAllit(){
|
||
if(!analysis || !analysis.allit || !allitToggle.checked) return null;
|
||
if(editor.selectionStart !== editor.selectionEnd) return null;
|
||
const pos = editor.selectionStart;
|
||
const before = editor.value.slice(0, pos);
|
||
const ln = before.split('\n').length - 1;
|
||
const col = pos - (before.lastIndexOf('\n') + 1);
|
||
const line = editor.value.split('\n')[ln];
|
||
if(!analysis.lines || analysis.lines[ln] !== line) return null;
|
||
const hit = analysis.allit.find(t=>t.l===ln && t.s<=col && col<t.e);
|
||
return hit ? hit.g : null;
|
||
}
|
||
|
||
function updateSpotlight(){
|
||
focusAllit = caretAllit();
|
||
setEmphasis(caretGid());
|
||
}
|
||
let analyzeSeq = 0; // guards against out-of-order responses
|
||
|
||
const SAMPLE_TEXT =
|
||
`I keep the cadence tucked beneath my tongue tonight
|
||
the city hums in amber under fading light
|
||
I trace a melody that never quite takes flight
|
||
and let the silence answer everything I write
|
||
|
||
I put my orange
|
||
four-inch
|
||
door hinge
|
||
in storage,
|
||
and ate porridge with George`;
|
||
|
||
/* ---------- drafts — multiple docs in localStorage, tabbed ---------- */
|
||
const DOCS_KEY = 'rhymepad.docs';
|
||
const docKey = id => 'rhymepad.doc.' + id;
|
||
const newId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||
|
||
function loadDocs(){
|
||
try{
|
||
const s = JSON.parse(localStorage.getItem(DOCS_KEY));
|
||
if(s && s.docs && s.docs.length && s.docs.some(d=>d.id===s.current)) return s;
|
||
}catch(e){}
|
||
// migrate the single-draft era; a brand-new visitor starts with the
|
||
// sample so the colors are visible before they type a word
|
||
const id = newId();
|
||
const text = localStorage.getItem('rhymepad.draft') ?? SAMPLE_TEXT;
|
||
localStorage.setItem(docKey(id), text);
|
||
const s = {docs: [{id, title: titleOf(text)}], current: id};
|
||
localStorage.setItem(DOCS_KEY, JSON.stringify(s));
|
||
return s;
|
||
}
|
||
function titleOf(text){
|
||
const l = (text || '').split('\n').find(l=>l.trim());
|
||
if(!l) return 'Untitled';
|
||
const t = l.trim();
|
||
return t.length > 26 ? t.slice(0, 25) + '…' : t;
|
||
}
|
||
function saveDocs(){
|
||
try{ localStorage.setItem(DOCS_KEY, JSON.stringify(docsState)); }catch(e){}
|
||
}
|
||
let docsState = loadDocs();
|
||
editor.value = localStorage.getItem(docKey(docsState.current)) || '';
|
||
|
||
function persist(){
|
||
try{
|
||
localStorage.setItem(docKey(docsState.current), editor.value);
|
||
const doc = docsState.docs.find(d=>d.id===docsState.current);
|
||
const title = titleOf(editor.value);
|
||
if(doc && doc.title !== title){
|
||
doc.title = title;
|
||
const el = draftsBar.querySelector('.dtab.active .dtitle');
|
||
if(el) el.textContent = title;
|
||
}
|
||
document.title = (title && title !== 'Untitled')
|
||
? title + ' · RhymePad' : 'RhymePad — rhyme scheme analyzer & writing pad for poets and rappers';
|
||
saveDocs();
|
||
}catch(e){ /* storage full/blocked */ }
|
||
}
|
||
|
||
const draftsBar = document.getElementById('draftsBar');
|
||
function renderTabs(){
|
||
draftsBar.innerHTML = '';
|
||
docsState.docs.forEach(doc=>{
|
||
const tab = document.createElement('div');
|
||
tab.className = 'dtab' + (doc.id === docsState.current ? ' active' : '');
|
||
const title = document.createElement('span');
|
||
title.className = 'dtitle';
|
||
title.textContent = doc.title || 'Untitled';
|
||
tab.appendChild(title);
|
||
if(doc.id === docsState.current && docsState.docs.length > 1){
|
||
const x = document.createElement('span');
|
||
x.className = 'x'; x.textContent = '×'; x.title = 'Delete this draft';
|
||
x.addEventListener('click', e=>{ e.stopPropagation(); deleteDoc(doc.id); });
|
||
tab.appendChild(x);
|
||
}
|
||
tab.addEventListener('click', ()=>{ if(doc.id !== docsState.current) openDoc(doc.id); });
|
||
draftsBar.appendChild(tab);
|
||
});
|
||
const plus = document.createElement('div');
|
||
plus.className = 'dtab new'; plus.textContent = '+'; plus.title = 'New draft';
|
||
plus.addEventListener('click', newDoc);
|
||
draftsBar.appendChild(plus);
|
||
}
|
||
function openDoc(id){
|
||
// remember where we were in the draft we're leaving
|
||
const cur = docsState.docs.find(d=>d.id===docsState.current);
|
||
if(cur){ cur.sel = editor.selectionStart; cur.scroll = editor.scrollTop; }
|
||
docsState.current = id;
|
||
saveDocs();
|
||
editor.value = localStorage.getItem(docKey(id)) || '';
|
||
analysis = null;
|
||
// scorched earth on every transient layer — no paint from the old
|
||
// draft can survive the transition
|
||
if(typeof stopRap === 'function') stopRap();
|
||
if(typeof closeGhostMenu === 'function') closeGhostMenu();
|
||
if(typeof ghost !== 'undefined') ghost = null;
|
||
if(typeof karaokeSpan !== 'undefined') karaokeSpan = null;
|
||
if(typeof activeFam !== 'undefined' && activeFam !== null) setEmphasis(null);
|
||
highlight.innerHTML = '';
|
||
stresslayer.innerHTML = '';
|
||
gutter.innerHTML = '';
|
||
renderTabs();
|
||
render(); analyze(); editor.focus();
|
||
if(typeof linkSoon === 'function') linkSoon();
|
||
if(typeof syncSampleChip === 'function') syncSampleChip();
|
||
const doc = docsState.docs.find(d=>d.id===id);
|
||
if(doc && doc.sel != null){
|
||
editor.setSelectionRange(doc.sel, doc.sel);
|
||
editor.scrollTop = doc.scroll || 0;
|
||
highlight.scrollTop = editor.scrollTop;
|
||
}
|
||
}
|
||
function newDoc(){
|
||
const id = newId();
|
||
docsState.docs.push({id, title: 'Untitled'});
|
||
localStorage.setItem(docKey(id), '');
|
||
openDoc(id);
|
||
}
|
||
function deleteDoc(id){
|
||
const doc = docsState.docs.find(d=>d.id===id);
|
||
const text = localStorage.getItem(docKey(id)) || '';
|
||
if(text.trim() && !confirm(`Delete “${doc.title}”?`)) return;
|
||
try{ if(text.trim()) localStorage.setItem('rhymepad.trash', text); }catch(e){}
|
||
localStorage.removeItem(docKey(id));
|
||
docsState.docs = docsState.docs.filter(d=>d.id!==id);
|
||
if(!docsState.docs.length){
|
||
const nid = newId();
|
||
docsState.docs = [{id: nid, title: 'Untitled'}];
|
||
localStorage.setItem(docKey(nid), '');
|
||
}
|
||
openDoc(docsState.docs[0].id);
|
||
}
|
||
renderTabs();
|
||
|
||
async function analyze(){
|
||
const text = editor.value;
|
||
const seq = ++analyzeSeq;
|
||
try{
|
||
const r = await fetch('/api/analyze', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({text})
|
||
});
|
||
if(seq !== analyzeSeq) return; // a newer request superseded us
|
||
analysis = await r.json();
|
||
backendOk = true;
|
||
if(typeof ghostSoon === 'function') ghostSoon(); // meter is fresh: re-rank
|
||
}catch(e){
|
||
if(seq !== analyzeSeq) return;
|
||
backendOk = false;
|
||
}
|
||
render();
|
||
}
|
||
// rhyme detection waits for the word, not the keystroke: mid-word the
|
||
// pad holds off until a real pause, so half-typed words never flicker
|
||
// into family colors; closing the word (space/punct/newline) analyzes
|
||
// at the usual quick delay
|
||
let analyzeT = null;
|
||
function analyzeSoon(){
|
||
clearTimeout(analyzeT);
|
||
const c = editor.value[editor.selectionStart - 1] || '';
|
||
analyzeT = setTimeout(analyze, /[A-Za-z'’]/.test(c) ? 700 : 180);
|
||
}
|
||
|
||
function render(){
|
||
persist();
|
||
const lines = editor.value.split('\n');
|
||
const tokByLine = {};
|
||
const allitByLine = {};
|
||
const openByLine = {};
|
||
const nearByLine = {};
|
||
const groupInfo = {};
|
||
if(analysis){
|
||
analysis.groups.forEach(g=>{ groupInfo[g.id] = g; });
|
||
analysis.tokens.forEach(t=>{ (tokByLine[t.l] ||= []).push(t); });
|
||
if(analysis.allit) analysis.allit.forEach(t=>{ (allitByLine[t.l] ||= []).push(t); });
|
||
if(analysis.open) analysis.open.forEach(t=>{ (openByLine[t.l] ||= []).push(t); });
|
||
if(analysis.near) analysis.near.forEach(t=>{ (nearByLine[t.l] ||= []).push(t); });
|
||
}
|
||
const colorOf = t => `var(--r${groupInfo[t.g].color % COLORS})`;
|
||
let html = '';
|
||
lines.forEach((line, i)=>{
|
||
// apply spans where the line still matches what the server analyzed;
|
||
// on an edited line, keep highlights over the unchanged prefix so
|
||
// typing at the end of a line doesn't blank it
|
||
const fresh = analysis && analysis.lines[i] === line;
|
||
let raw = [];
|
||
if(fresh){
|
||
raw = tokByLine[i] || [];
|
||
}else if(analysis && typeof analysis.lines[i] === 'string'){
|
||
const old = analysis.lines[i];
|
||
let cp = 0;
|
||
const n = Math.min(old.length, line.length);
|
||
while(cp < n && old[cp] === line[cp]) cp++;
|
||
raw = (tokByLine[i] || []).filter(t=>t.e <= cp);
|
||
}
|
||
const toks = rhymeToggle.checked ? raw.filter(t=>groupInfo[t.g]) : [];
|
||
const words = toks.filter(t=>!t.ph);
|
||
const phrases = toks.filter(t=>t.ph);
|
||
const als = (allitToggle.checked && fresh) ? (allitByLine[i] || []) : [];
|
||
const opens = [];
|
||
const kj = (karaokeSpan && karaokeSpan.l === i) ? karaokeSpan : null;
|
||
const nears = (rhymeToggle.checked && fresh) ? (nearByLine[i] || []) : [];
|
||
let h = '';
|
||
if(!toks.length && !als.length && !opens.length){
|
||
h = esc(line);
|
||
}else{
|
||
const cuts = new Set([0, line.length]);
|
||
toks.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); if(t.rs != null) cuts.add(t.rs); });
|
||
als.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); });
|
||
opens.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); });
|
||
nears.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); });
|
||
if(kj){ cuts.add(kj.s); cuts.add(kj.e); }
|
||
const pts = [...cuts].sort((a,b)=>a-b);
|
||
for(let k = 0; k < pts.length - 1; k++){
|
||
const a = pts[k], b = pts[k+1];
|
||
const text = esc(line.slice(a, b));
|
||
if(!text) continue;
|
||
const w = words.find(t=>t.s <= a && b <= t.e);
|
||
const p = phrases.find(t=>t.s <= a && b <= t.e);
|
||
const al = als.find(t=>t.s <= a && b <= t.e);
|
||
const op = opens.find(t=>t.s <= a && b <= t.e);
|
||
const nr = nears.find(t=>t.s <= a && b <= t.e);
|
||
const kk = kj && kj.s <= a && b <= kj.e;
|
||
if(!w && !p && !al && !op && !nr && !kk){ h += text; continue; }
|
||
// whole word fills dimly; the rhyming part gets a bright underline.
|
||
// gray = an ending still waiting for its answer
|
||
let style = '';
|
||
let tk = '', dg = '';
|
||
const shadows = [];
|
||
if(w || p){
|
||
const t = w || p;
|
||
let alpha = !t.ph ? (t.end ? 34 : 19) : (t.end ? 24 : 14);
|
||
const str = t.str != null ? t.str : ((groupInfo[t.g] && groupInfo[t.g].strength) || 1);
|
||
alpha = Math.round(alpha * (0.4 + 0.6 * str)); // brightness = this word's rhyme strength
|
||
dg = ` data-g="${t.g}"`;
|
||
style += `background:color-mix(in srgb, ${colorOf(t)} ${alpha}%, transparent);`;
|
||
if(w){ tk = ` data-tk="${i}:${w.s}"`; if(w.rs == null || a >= w.rs) tk += ` data-tl="${i}:${w.s}"`; }
|
||
// the whole rhyming word takes a quiet tint of the family color
|
||
const mix = Math.round(28 + 32 * str);
|
||
style += `color:color-mix(in srgb, ${colorOf(t)} ${mix}%, var(--ink));`;
|
||
}
|
||
if(al) shadows.push(`inset 0 -2px 0 0 color-mix(in srgb, var(--r${al.g % COLORS}) 75%, transparent)`);
|
||
if(nr) style += 'text-decoration:underline dotted color-mix(in srgb, var(--accent-2) 60%, transparent);text-underline-offset:3px;';
|
||
if(shadows.length) style += `box-shadow:${shadows.join(',')};`;
|
||
if(kk) style += 'background:var(--accent);color:#1a120c;font-weight:600;';
|
||
h += `<span class="hseg"${tk}${dg} style="${style}">${text}</span>`;
|
||
}
|
||
}
|
||
const lcls = 'lmark' + (/^\s*#/.test(line) ? ' hdr' : /^\s*\[/.test(line) ? ' anno' : '');
|
||
if(ghost && ghost.line === i && ghost.base === line){
|
||
const gc = ghost.cands[ghost.sel] || {};
|
||
const disp = gc.comp
|
||
? ghost.text.slice(ghost.partial.length)
|
||
: (line.endsWith(' ') ? '' : ' ') + ghost.text;
|
||
h += `<span class="ghost">${esc(disp)}` +
|
||
`${gc.fit ? '<span class="ghost-fit"> ✦</span>' : ''}</span><span class="ghost-key">tab</span>`;
|
||
}
|
||
html += (line ? `<span class="${lcls}" data-l="${i}">${h}</span>` : '') + '\n';
|
||
});
|
||
highlight.innerHTML = html;
|
||
if(typeof syncSampleChip === 'function') syncSampleChip();
|
||
if(activeFam != null)
|
||
highlight.querySelectorAll(`.hseg[data-g="${activeFam}"]`).forEach(s=>{
|
||
s.classList.add('lit');
|
||
const lm = s.closest('.lmark'); if(lm) lm.classList.add('litline');
|
||
});
|
||
renderStress(lines);
|
||
syncLayerHeights();
|
||
renderGutter();
|
||
highlight.scrollTop = editor.scrollTop;
|
||
highlight.scrollLeft = editor.scrollLeft;
|
||
buildReadout();
|
||
}
|
||
|
||
function spineFor(memberLines, color, cls){
|
||
const marks = [...highlight.querySelectorAll('.lmark')]
|
||
.filter(m=>memberLines.has(+m.dataset.l));
|
||
if(marks.length < 2) return;
|
||
const off = editor.scrollTop;
|
||
let top = Infinity, bot = -Infinity;
|
||
const centers = [];
|
||
marks.forEach(m=>{
|
||
const t = m.offsetTop - off, h2 = m.offsetHeight;
|
||
top = Math.min(top, t); bot = Math.max(bot, t + h2);
|
||
centers.push(t + h2 / 2);
|
||
});
|
||
const br = document.createElement('div');
|
||
br.className = 'bracket' + (cls ? ' ' + cls : '');
|
||
br.style.top = (top + 5) + 'px';
|
||
br.style.height = (bot - top - 10) + 'px';
|
||
br.style.color = `color-mix(in srgb, ${color} 32%, transparent)`;
|
||
gutter.appendChild(br);
|
||
centers.forEach(c=>{
|
||
const tk = document.createElement('div');
|
||
tk.className = 'tick' + (cls ? ' ' + cls : '');
|
||
tk.style.top = (c - 1.25) + 'px';
|
||
tk.style.background = `color-mix(in srgb, ${color} 45%, transparent)`;
|
||
gutter.appendChild(tk);
|
||
});
|
||
}
|
||
|
||
function renderGutter(){
|
||
gutter.innerHTML = '';
|
||
if(!analysis) return;
|
||
if(activeFam !== null && rhymeToggle.checked){
|
||
const g = analysis.groups.find(x=>x.id === activeFam);
|
||
if(g){
|
||
const ls = new Set();
|
||
analysis.tokens.forEach(t=>{ if(t.g === activeFam) ls.add(t.l); });
|
||
spineFor(ls, `var(--r${g.color % COLORS})`, '');
|
||
}
|
||
}
|
||
if(focusAllit !== null){
|
||
const ls = new Set();
|
||
analysis.allit.forEach(t=>{ if(t.g === focusAllit) ls.add(t.l); });
|
||
spineFor(ls, `var(--r${focusAllit % COLORS})`, 'allit');
|
||
}
|
||
}
|
||
|
||
|
||
function syncLayerHeights(){
|
||
[highlight, stresslayer].forEach(l=>{
|
||
let sp = l.querySelector('.lspacer');
|
||
if(!sp){
|
||
sp = document.createElement('div');
|
||
sp.className = 'lspacer';
|
||
l.appendChild(sp);
|
||
}
|
||
sp.style.height = '0px';
|
||
const diff = editor.scrollHeight - l.scrollHeight;
|
||
if(diff > 0) sp.style.height = diff + 'px';
|
||
});
|
||
}
|
||
|
||
function cadenceColors(){
|
||
// exact stress-contour matches (5+ syllables) form a flow family
|
||
const map = {};
|
||
if(!analysis || !analysis.meter) return map;
|
||
const byPat = {};
|
||
analysis.meter.forEach(m=>{
|
||
if(!m.stress || m.stress.length < 5) return;
|
||
// flexible monosyllables (x) read as stressed in delivery, so
|
||
// "x01" and "101" are the same flow
|
||
const key = m.stress.replace(/x/g, '1');
|
||
(byPat[key] ||= []).push(m.l);
|
||
});
|
||
let fid = 0;
|
||
Object.keys(byPat).sort().forEach(pat=>{
|
||
const lns = byPat[pat];
|
||
if(lns.length >= 2){ lns.forEach(l=>{ map[l] = fid % COLORS; }); fid++; }
|
||
});
|
||
return map;
|
||
}
|
||
|
||
function renderStress(lines){
|
||
if(!stressToggle.checked){ stresslayer.innerHTML = ''; return; }
|
||
const byLine = {};
|
||
if(analysis && analysis.stress) analysis.stress.forEach(s=>{ (byLine[s.l] ||= []).push(s); });
|
||
const cmap = cadenceColors();
|
||
let html = '';
|
||
lines.forEach((line, i)=>{
|
||
const fresh = analysis && analysis.lines[i] === line;
|
||
let raw = [];
|
||
if(fresh){ raw = byLine[i] || []; }
|
||
else if(analysis && typeof analysis.lines[i] === 'string'){
|
||
const old = analysis.lines[i]; let cp = 0;
|
||
const n = Math.min(old.length, line.length);
|
||
while(cp < n && old[cp] === line[cp]) cp++;
|
||
raw = (byLine[i] || []).filter(s=>s.e <= cp);
|
||
}
|
||
const spans = raw.slice().sort((a,b)=>a.s-b.s);
|
||
let pos = 0, h2 = '';
|
||
spans.forEach(s=>{
|
||
if(s.s < pos) return;
|
||
h2 += esc(line.slice(pos, s.s));
|
||
const dots = [...s.st].map(c=> c === '0' ? '\u25CB' : '\u25CF').join('');
|
||
const col = cmap[i] != null ? ` style="color:var(--r${cmap[i]})"` : '';
|
||
h2 += `<span class="sw">${esc(line.slice(s.s, s.e))}<span class="sd"${col}>${dots}</span></span>`;
|
||
pos = s.e;
|
||
});
|
||
h2 += esc(line.slice(pos));
|
||
html += h2 + '\n';
|
||
});
|
||
stresslayer.innerHTML = html;
|
||
stresslayer.scrollTop = editor.scrollTop;
|
||
stresslayer.scrollLeft = editor.scrollLeft;
|
||
}
|
||
|
||
function caretLine(){
|
||
return editor.value.slice(0, editor.selectionStart).split('\n').length - 1;
|
||
}
|
||
function caretStanza(){
|
||
if(!analysis || !analysis.stanzas.length) return null;
|
||
const ln = caretLine();
|
||
return analysis.stanzas.find(s=>s.lines.includes(ln))
|
||
|| analysis.stanzas.findLast(s=>s.lines[0] <= ln)
|
||
|| analysis.stanzas[0];
|
||
}
|
||
|
||
function buildReadout(){
|
||
// the stats bar is gone — this element only ever speaks up when the
|
||
// engine is unreachable (and hosts the transient "saved" flash)
|
||
schemeReadout.innerHTML = backendOk ? ''
|
||
: '<span class="offline">backend offline — run: uv run granian --interface asgi app:api</span>';
|
||
}
|
||
|
||
const sampleChip = document.getElementById('sampleChip');
|
||
const SAMPLE = `# The Tinted Verse
|
||
|
||
I paint the placement of creation in elation,
|
||
a constellation forming in the patience of the nation.
|
||
|
||
cold smoke, gold rope
|
||
a slow boat, a low note
|
||
broke, awoke, I wrote the whole quote
|
||
|
||
the shimmer in the river is a glimmer and a flicker,
|
||
the whisper of the scripture is the rigor in the mixture.`;
|
||
function syncSampleChip(){ sampleChip.hidden = !!editor.value.trim() || editor.readOnly; }
|
||
sampleChip.addEventListener('click', ()=>{
|
||
editor.value = SAMPLE;
|
||
localStorage.setItem(docKey(docsState.current), SAMPLE);
|
||
syncSampleChip();
|
||
render(); analyze(); editor.focus();
|
||
});
|
||
syncSampleChip();
|
||
|
||
editor.addEventListener('input', ()=>{
|
||
if(ghost){
|
||
const cur = editor.value.split('\n')[ghost.line];
|
||
if(cur !== ghost.base){ ghost = null; closeGhostMenu(); }
|
||
}
|
||
render(); analyzeSoon(); ghostSoon(); linkSoon();
|
||
syncSampleChip();
|
||
});
|
||
editor.addEventListener('scroll', ()=>{ highlight.scrollTop = stresslayer.scrollTop = editor.scrollTop; highlight.scrollLeft = stresslayer.scrollLeft = editor.scrollLeft; renderGutter(); });
|
||
const editorShell = document.querySelector('.editor-shell');
|
||
|
||
/* ---- toggle legends: hover a pill, learn the layer ------------------ */
|
||
const LEGENDS = {
|
||
rhymeToggle: `
|
||
<h4>Rhyme</h4>
|
||
<p><b style="color:var(--ink)">Same color = same sound.</b> Detection is
|
||
phonetic, not spelling — yes, <i>orange</i> rhymes with <i>door hinge</i>.
|
||
16 hues, assigned so neighboring families never share:</p>
|
||
<div class="lg-pal">${Array.from({length:16},(_,i)=>`<span style="background:var(--r${i})"></span>`).join('')}</div>
|
||
<p><b style="color:var(--ink)">Brightness = strength.</b> Perfect rhymes
|
||
blaze, slant rhymes sit back; within a family the anchors out-glow the
|
||
loose attachments.</p>
|
||
<div class="lg-key">
|
||
<span class="chip" style="background:color-mix(in srgb,var(--r1) 34%,transparent);color:color-mix(in srgb,var(--r1) 55%,var(--ink));box-shadow:inset 0 -1.5px 0 0 color-mix(in srgb,var(--r1) 42%,transparent)">tonight</span>
|
||
<i>perfect rhyme — the underline marks the exact rhyming part</i>
|
||
<span class="chip" style="background:color-mix(in srgb,var(--r1) 14%,transparent);color:color-mix(in srgb,var(--r1) 32%,var(--ink))">mine</span>
|
||
<i>slant rhyme — fainter fill, weaker grip</i>
|
||
<span class="chip" style="text-decoration:underline dotted color-mix(in srgb,var(--accent-2) 70%,transparent);text-underline-offset:3px">bond</span>
|
||
<i>near-miss — one sound from rhyming; a small edit locks it</i>
|
||
</div>
|
||
<p><b style="color:var(--ink)">Hover any word</b> to light its whole
|
||
family. The philosophy throughout: color only what a listener would
|
||
actually hear.</p>`,
|
||
allitToggle: `
|
||
<h4>Alliteration</h4>
|
||
<p>Words sharing a <b style="color:var(--ink)">head sound</b> get an
|
||
underline — fills belong to rhyme (tail sounds), underlines to
|
||
alliteration (front sounds), so the two never collide.</p>
|
||
<div class="lg-demo">
|
||
<span style="box-shadow:inset 0 -2px 0 0 var(--r2)">sipping</span>
|
||
<span style="box-shadow:inset 0 -2px 0 0 var(--r2)">cider</span>
|
||
<span style="box-shadow:inset 0 -2px 0 0 var(--r2)">slow</span>
|
||
</div>
|
||
<p>Runs need three shared onsets — or a tight side-by-side pair
|
||
of bigger words (<i>sordid solutions</i>).</p>`,
|
||
stressToggle: `
|
||
<h4>Rhythm</h4>
|
||
<p>Sheet music for your flow: a dot under every syllable —
|
||
<b style="color:var(--ink)">● stressed, ○ unstressed</b>.</p>
|
||
<div class="lg-demo">placement of creation<br>
|
||
<span style="font-size:9px;letter-spacing:4px">●○ ○ ○●○</span></div>
|
||
<p>Lines whose stress patterns echo each other share a dot color —
|
||
a cadence family. Scan the dots to see where your bars match.</p>`,
|
||
suggestToggle: `
|
||
<h4>Suggest</h4>
|
||
<p>Pause at the end of the line you're writing — a real pause, it
|
||
won't interrupt a breath between words — and a quiet ghost offers a
|
||
landing word that answers the stanza's open ending. It knows your
|
||
song: words that echo what you're writing about lead, marked
|
||
<b style="color:var(--accent)">✦</b>, and it keeps the grammar of
|
||
your line. Start typing a word and it narrows to completions.</p>
|
||
<p><kbd>Tab</kbd> opens the candidates (or summons the ghost when
|
||
there isn't one) — syllable dots show which fits the bar, <b>~</b>
|
||
marks slant. <kbd>Enter</kbd> lands it. <kbd>Esc</kbd> means no for
|
||
this line, and it listens. It never types for you; it only
|
||
offers.</p>`,
|
||
exploreToggle: `
|
||
<h4>Explore</h4>
|
||
<p>Hover, for fingers: the pad goes read-only so a tap lights up the
|
||
rhyme family under it — and the keyboard stays out of the way.</p>`,
|
||
};
|
||
const legendEl = document.createElement('div');
|
||
legendEl.id = 'legend';
|
||
legendEl.hidden = true;
|
||
document.body.appendChild(legendEl);
|
||
let legendTimer = 0;
|
||
if(window.matchMedia('(hover: hover)').matches){
|
||
document.querySelectorAll('.mtoggle').forEach(lab=>{
|
||
const input = lab.querySelector('input');
|
||
const html = input && LEGENDS[input.id];
|
||
if(!html) return;
|
||
lab.addEventListener('mouseenter', ()=>{
|
||
legendTimer = setTimeout(()=>{
|
||
legendEl.innerHTML = html;
|
||
legendEl.hidden = false;
|
||
legendEl.style.visibility = 'hidden';
|
||
const r = lab.getBoundingClientRect();
|
||
const w = legendEl.offsetWidth, hh = legendEl.offsetHeight;
|
||
let x = Math.min(Math.max(8, r.left + r.width / 2 - w / 2),
|
||
innerWidth - w - 8);
|
||
let y = r.top - hh - 10;
|
||
if(y < 8) y = r.bottom + 10;
|
||
legendEl.style.left = x + 'px';
|
||
legendEl.style.top = y + 'px';
|
||
legendEl.style.visibility = '';
|
||
}, 350);
|
||
});
|
||
lab.addEventListener('mouseleave', ()=>{
|
||
clearTimeout(legendTimer);
|
||
legendEl.hidden = true;
|
||
});
|
||
});
|
||
}
|
||
function setEmphasis(g){
|
||
if(!rhymeToggle.checked) g = null; // no rhyme layer, no focus
|
||
if(g === activeFam) return;
|
||
activeFam = g;
|
||
editorShell.classList.toggle('focusing', g !== null);
|
||
highlight.querySelectorAll('.hseg.lit').forEach(s=>s.classList.remove('lit'));
|
||
highlight.querySelectorAll('.lmark.litline').forEach(s=>s.classList.remove('litline'));
|
||
if(g !== null)
|
||
highlight.querySelectorAll(`.hseg[data-g="${g}"]`).forEach(s=>{
|
||
s.classList.add('lit');
|
||
const lm = s.closest('.lmark'); if(lm) lm.classList.add('litline');
|
||
});
|
||
renderGutter();
|
||
buildReadout();
|
||
}
|
||
let hoverRAF = 0;
|
||
editor.addEventListener('mousemove', e=>{
|
||
if(hoverRAF) return;
|
||
hoverRAF = requestAnimationFrame(()=>{
|
||
hoverRAF = 0;
|
||
const g = gidAtPoint(e.clientX, e.clientY);
|
||
setEmphasis(g != null ? g : caretGid());
|
||
});
|
||
});
|
||
editor.addEventListener('mouseleave', ()=> setEmphasis(caretGid()));
|
||
editor.addEventListener('keyup', ()=>{ updateSpotlight(); buildReadout(); ghostSoon(); });
|
||
editor.addEventListener('click', ()=>{
|
||
if(editor.readOnly) return; // explore mode: pointerdown already handled it
|
||
updateSpotlight(); buildReadout(); ghostSoon();
|
||
});
|
||
|
||
/* ---- explore mode: hover for fingers ---------------------------------
|
||
On touch there's no hover, and tapping the pad summons the keyboard.
|
||
Explore makes the draft read-only so a tap does what the mouse does:
|
||
light up the rhyme family under it, keyboard nowhere in sight. */
|
||
const exploreToggle = document.getElementById('exploreToggle');
|
||
exploreToggle.addEventListener('change', ()=>{
|
||
editor.readOnly = exploreToggle.checked;
|
||
if(exploreToggle.checked) editor.blur();
|
||
else setEmphasis(caretGid());
|
||
});
|
||
editor.addEventListener('pointerdown', e=>{
|
||
if(!editor.readOnly) return;
|
||
e.preventDefault(); // no focus, no keyboard — scrolling still works
|
||
setEmphasis(gidAtPoint(e.clientX, e.clientY));
|
||
});
|
||
|
||
// ---- ghost rhyme completion: the pad suggests your landing word ----
|
||
let ghost = null; // {line, base, text}
|
||
let ghostDismissed = ''; // line|base the writer waved off with Esc
|
||
const ghostCache = {}; // target word -> ranked candidate list
|
||
const knownWord = {}; // fragment -> is it a real word (zipf gate)
|
||
const suggestToggle = document.getElementById('suggestToggle');
|
||
suggestToggle.checked = localStorage.getItem('rhymepad.suggest') !== '0';
|
||
suggestToggle.addEventListener('change', ()=>{
|
||
localStorage.setItem('rhymepad.suggest', suggestToggle.checked ? '1' : '0');
|
||
if(!suggestToggle.checked && ghost){ ghost = null; render(); }
|
||
});
|
||
|
||
function caretLineCol(){
|
||
const pos = editor.selectionStart;
|
||
const before = editor.value.slice(0, pos);
|
||
const ln = before.split('\n').length - 1;
|
||
return {ln, col: pos - (before.lastIndexOf('\n') + 1)};
|
||
}
|
||
|
||
function lastWordOf(s){
|
||
const m = s.match(/[A-Za-z']+(?=[^A-Za-z']*$)/);
|
||
return m ? m[0].toLowerCase() : null;
|
||
}
|
||
|
||
// the targets, nearest-need first: every UNANSWERED ending in this
|
||
// stanza (the gray fills, begging), then each established end-rhyme
|
||
// family's most recent word (extending a thread is a legit landing).
|
||
// Fallback when the stanza offers nothing: the previous line's ending.
|
||
function ghostTargets(lines, ln){
|
||
const out = [], seen = new Set();
|
||
const fresh = l => analysis && analysis.lines[l] === lines[l];
|
||
const openLines = new Set();
|
||
if(analysis) (analysis.open || []).forEach(o=>{ if(fresh(o.l)) openLines.add(o.l); });
|
||
let start = ln;
|
||
for(let j = ln - 1; j >= 0 && lines[j].trim(); j--) start = j;
|
||
let fallback = null;
|
||
for(let j = ln - 1; j >= start; j--){
|
||
const prev = lines[j];
|
||
if(/^\s*[#\[]/.test(prev)) continue; // annotations don't end lines
|
||
const w = lastWordOf(prev);
|
||
if(!w) continue;
|
||
if(!fallback) fallback = {target: w, tline: j, color: null, open: false};
|
||
if(openLines.has(j) && !seen.has(w)){
|
||
seen.add(w);
|
||
out.push({target: w, tline: j, color: null, open: true});
|
||
}
|
||
}
|
||
if(analysis){
|
||
const gcolor = {};
|
||
(analysis.groups || []).forEach(g=>{ gcolor[g.id] = g.color; });
|
||
const byFam = {};
|
||
(analysis.tokens || []).forEach(t=>{
|
||
if(t.ph || !t.end || t.l >= ln || t.l < start || !fresh(t.l)) return;
|
||
const w = lines[t.l].slice(t.s, t.e).toLowerCase();
|
||
if(!byFam[t.g] || byFam[t.g].tline < t.l)
|
||
byFam[t.g] = {target: w, tline: t.l, color: gcolor[t.g] ?? null, open: false};
|
||
});
|
||
Object.values(byFam).sort((a, b)=>b.tline - a.tline).forEach(f=>{
|
||
if(!seen.has(f.target)){ seen.add(f.target); out.push(f); }
|
||
});
|
||
}
|
||
if(!out.length && fallback) out.push(fallback);
|
||
return out.slice(0, 3);
|
||
}
|
||
|
||
async function loadGhostCands(target){
|
||
if(target in ghostCache) return;
|
||
ghostCache[target] = null; // in flight
|
||
try{
|
||
// draft-aware: candidates that echo the song carry a fit
|
||
const r = await fetch('/api/suggest', {method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({word: target, text: editor.value})});
|
||
const d = await r.json();
|
||
const shape = (w, near, multi)=>({word: w.word.toLowerCase(), syl: w.syl || 1,
|
||
z: w.z || 0, fit: w.fit || null, fitn: w.fitn || 0, pos: w.pos || '', near, multi});
|
||
ghostCache[target] = (d.words || []).map(w=>shape(w, false, false))
|
||
.concat(((d.multis || []).filter(w=>w.word)).map(w=>shape(w, false, true)))
|
||
.concat((d.near || []).map(w=>shape(w, true, false)));
|
||
}catch(e){ delete ghostCache[target]; }
|
||
}
|
||
|
||
// follows-cache: context -> words that commonly come next, for ranking
|
||
// candidates that read like language. The trigram tier sees idioms the
|
||
// bigram can't: "from time to _time_", not "to _my_".
|
||
const followsCache = {};
|
||
async function loadFollows(key, prev, prev2){
|
||
if(key in followsCache) return;
|
||
followsCache[key] = null; // in flight
|
||
try{
|
||
const q = `prev=${encodeURIComponent(prev)}` + (prev2 ? `&prev2=${encodeURIComponent(prev2)}` : '');
|
||
const d = await (await fetch(`/api/follows?${q}`)).json();
|
||
followsCache[key] = {bi: new Set(d.words || []), tri: new Set(d.tri || [])};
|
||
}catch(e){ delete followsCache[key]; }
|
||
}
|
||
|
||
// short fragments that are real words — appends allowed without asking
|
||
const SHORT_OK = new Set(('a i ah an am as be by do go ha he hi if in is it ma me ' +
|
||
'my no of oh on or so to uh up us we ya yo').split(' '));
|
||
|
||
// grammar: after a determiner this slot ENDS the line, so it wants a
|
||
// noun — an adjective-only word ("my own…") dangles, a verb-only word
|
||
// ("the ignite") is nonsense. After a modal or "to" it wants a verb.
|
||
const DET_PREV = new Set(('the a an my your his her its our their this that these ' +
|
||
'those some no every each another').split(' '));
|
||
const VERB_PREV = new Set(("to will would can could should must might shall don't " +
|
||
"doesn't didn't won't can't couldn't wouldn't shouldn't gonna wanna gotta lemme").split(' '));
|
||
function grammarTier(c, prev){
|
||
if(!prev) return 0;
|
||
const pos = c.pos || '';
|
||
if(DET_PREV.has(prev))
|
||
return pos.includes('n') ? 0 : !pos || pos.includes('a') ? 1 : 2;
|
||
if(VERB_PREV.has(prev)) return !pos ? 1 : (pos.includes('v') ? 0 : 2);
|
||
return 0;
|
||
}
|
||
|
||
let lastType = 0; // when the writer last touched a key
|
||
let forceGhost = false; // Tab asked for the ghost explicitly
|
||
let idleTimer = null;
|
||
const GHOST_IDLE = 800; // a real pause, not a breath between words
|
||
// words the writer walked past (Enter) or waved off (Esc) — offering
|
||
// the same word line after line is nagging, not helping. Tab still
|
||
// shows everything.
|
||
const ghostPassed = new Set();
|
||
|
||
async function computeGhost(){
|
||
const clear = ()=>{ if(ghost){ ghost = null; closeGhostMenu(); render(); } };
|
||
if(!suggestToggle.checked || !rhymeToggle.checked || editor.readOnly) return clear();
|
||
if(editor.selectionStart !== editor.selectionEnd) return clear();
|
||
const lines = editor.value.split('\n');
|
||
const {ln, col} = caretLineCol();
|
||
const line = lines[ln] || '';
|
||
if(col !== line.length || /^\s*[#\[]/.test(line)) return clear();
|
||
if(!line.trim()){
|
||
// a fresh line: warm the caches now so the pause costs nothing
|
||
ghostTargets(lines, ln).forEach(t=>loadGhostCands(t.target));
|
||
return clear();
|
||
}
|
||
const targets = ghostTargets(lines, ln);
|
||
if(!targets.length) return clear();
|
||
// Esc means no for this line — stay quiet however much they type;
|
||
// Tab can always ask again
|
||
if(!forceGhost && ghostDismissed === ln + '|' + targets[0].target) return clear();
|
||
const missing = targets.filter(t=>!(t.target in ghostCache));
|
||
if(missing.length){
|
||
await Promise.all(missing.map(t=>loadGhostCands(t.target)));
|
||
return computeGhost(); // the caret may have moved while we fetched
|
||
}
|
||
if(targets.some(t=>ghostCache[t.target] === null)) return; // in flight — hold
|
||
const cur = lastWordOf(line);
|
||
// already lands? the pad's own analysis is the judge: an ending that
|
||
// joined any end-rhyme family means the ghost's work is done
|
||
if(cur && targets.some(t=>t.target === cur)) return clear();
|
||
const fresh = l => analysis && analysis.lines[l] === lines[l];
|
||
if(cur && fresh(ln)){
|
||
const endG = l => {
|
||
let g = null;
|
||
analysis.tokens.forEach(t=>{ if(t.l === l && !t.ph && t.end) g = t.g; });
|
||
return g;
|
||
};
|
||
const g1 = endG(ln);
|
||
if(g1 !== null && targets.some(t=>fresh(t.tline) && endG(t.tline) === g1)) return clear();
|
||
}
|
||
// merge the pools, nearest-need target first; first claim on a word wins
|
||
const seenW = new Set();
|
||
const cands = [];
|
||
targets.forEach((t, oi)=>{
|
||
(ghostCache[t.target] || []).forEach(c=>{
|
||
if(seenW.has(c.word) || c.word === t.target) return;
|
||
seenW.add(c.word);
|
||
cands.push({...c, o: oi, tline: t.tline, color: t.color});
|
||
});
|
||
});
|
||
if(!cands.length) return clear();
|
||
if(cur && cands.some(c=>c.word === cur)) return clear(); // already lands
|
||
// a trailing fragment turns matching candidates into COMPLETIONS:
|
||
// type "fi" and the ghost narrows to fire, inserting just the rest
|
||
const partial = /[A-Za-z']$/.test(line) ? cur : null;
|
||
// skip words already spent as line endings in this draft
|
||
const spent = new Set(lines.map(l=>lastWordOf(l)).filter(Boolean));
|
||
let avail = cands.filter(c=>!spent.has(c.word.split(' ').pop()) && c.word !== cur
|
||
&& (forceGhost || !ghostPassed.has(c.word)))
|
||
.map(c=>({...c, comp: !!(partial && c.word.startsWith(partial)
|
||
&& c.word.length > partial.length)}));
|
||
if(!avail.length) return clear();
|
||
// a fragment nothing completes: only append after it if it's a real
|
||
// word — never bolt a suggestion onto half of one ("brai throw")
|
||
if(partial && !avail.some(c=>c.comp)){
|
||
if(partial.length <= 2){
|
||
if(!SHORT_OK.has(partial)) return clear();
|
||
}else{
|
||
if(!(partial in knownWord)){
|
||
try{
|
||
const d = await (await fetch(`/api/zipf?word=${encodeURIComponent(partial)}`)).json();
|
||
knownWord[partial] = (d.zipf || 0) >= 2.3;
|
||
}catch(e){ return; }
|
||
return computeGhost(); // the caret may have moved while we fetched
|
||
}
|
||
if(knownWord[partial] === null) return; // in flight — hold
|
||
if(!knownWord[partial]) return clear();
|
||
}
|
||
}
|
||
// what comes right before the slot? phrase-rank against it
|
||
const ws = (line.match(/[A-Za-z']+/g) || []).map(w=>w.toLowerCase());
|
||
const at = ws.length - (partial ? 2 : 1);
|
||
const prevWord = at >= 0 ? ws[at] : null;
|
||
const prev2 = at >= 1 ? ws[at - 1] : null;
|
||
const fkey = prevWord ? (prev2 ? prev2 + ' ' : '') + prevWord : null;
|
||
if(fkey && !(fkey in followsCache)){
|
||
await loadFollows(fkey, prevWord, prev2);
|
||
return computeGhost();
|
||
}
|
||
const follows = (fkey && followsCache[fkey]) || {bi: new Set(), tri: new Set()};
|
||
// bar gaps, per target line (when the meter is fresh for both ends)
|
||
const meterFresh = i => fresh(i) && analysis.meter && analysis.meter[i];
|
||
const gaps = targets.map(t=>(t.tline >= 0 && meterFresh(t.tline) && meterFresh(ln))
|
||
? analysis.meter[t.tline].syl - analysis.meter[ln].syl : 0);
|
||
// a completion's typed vowels are already counted in this line's bar
|
||
const pv = partial ? (partial.match(/[aeiouy]+/g) || []).length : 0;
|
||
// tier order is the product: completions of what's typed, grammar
|
||
// (the slot's part of speech), nearest need (open endings before
|
||
// family extensions), idiom-completers (trigram — sharp), then rhyme
|
||
// quality, then song echoes. The loose bigram only breaks ties BELOW
|
||
// rhyme quality — "the guy" must never beat a perfect rhyme.
|
||
avail = avail.map((c, i)=>({c, i,
|
||
p: c.comp ? 0 : 1,
|
||
g: grammarTier(c, prevWord),
|
||
o: c.o,
|
||
t: follows.tri.has(c.word.split(' ')[0]) ? 0 : 1,
|
||
f: -(c.fitn || 0),
|
||
n: c.near ? 1 : 0,
|
||
b: follows.bi.has(c.word.split(' ')[0]) ? 0 : 1,
|
||
m: gaps[c.o] >= 1 ? Math.abs((c.comp ? c.syl - pv : c.syl) - gaps[c.o]) : 0}))
|
||
.sort((a, b)=>a.p - b.p || a.g - b.g || a.o - b.o || a.t - b.t || a.n - b.n
|
||
|| a.f - b.f || a.b - b.b || a.m - b.m || a.i - b.i)
|
||
.map(x=>x.c);
|
||
avail = avail.slice(0, 8);
|
||
// patience: a brand-new append ghost waits for a real pause — but a
|
||
// completion of what's mid-keystroke helps NOW, and Tab means now
|
||
if(!avail[0].comp && !ghost && !forceGhost){
|
||
const wait = GHOST_IDLE - (Date.now() - lastType);
|
||
if(wait > 0){
|
||
clearTimeout(idleTimer);
|
||
idleTimer = setTimeout(computeGhost, wait + 20);
|
||
return;
|
||
}
|
||
}
|
||
const sig = avail.map(c=>c.word + (c.comp ? '+' : '')).join();
|
||
if(!ghost || ghost.line !== ln || ghost.base !== line || ghost.sig !== sig){
|
||
// steady hands: if the word we're already showing is still a live
|
||
// candidate, keep showing it — don't fidget over rank shuffles
|
||
let sel = 0;
|
||
if(ghost && ghost.line === ln && ghost.text){
|
||
const j = avail.findIndex(c=>c.word === ghost.text);
|
||
if(j > 0) sel = j;
|
||
}
|
||
ghost = {line: ln, base: line, text: avail[sel].word, cands: avail, sel,
|
||
target: targets[0].target, partial, sig};
|
||
render();
|
||
}
|
||
}
|
||
|
||
// ---- the Tab dropdown: browse the candidates at the caret ----
|
||
let ghostMenu = null;
|
||
function closeGhostMenu(){ if(ghostMenu){ ghostMenu.remove(); ghostMenu = null; } }
|
||
function acceptGhost(word){
|
||
const pos = editor.selectionStart;
|
||
const line = editor.value.split('\n')[ghost.line] || '';
|
||
const c = ghost.cands.find(x=>x.word === word);
|
||
const insert = c && c.comp
|
||
? word.slice(ghost.partial.length) // finish the word
|
||
: (line.endsWith(' ') ? '' : ' ') + word; // land a new one
|
||
editor.setRangeText(insert, pos, pos, 'end');
|
||
ghost = null; closeGhostMenu();
|
||
render(); analyzeSoon(); buildReadout();
|
||
editor.focus();
|
||
}
|
||
function openGhostMenu(){
|
||
closeGhostMenu();
|
||
if(!ghost) return;
|
||
ghostMenu = document.createElement('div');
|
||
ghostMenu.className = 'ghost-menu';
|
||
ghost.cands.forEach((c, i)=>{
|
||
const it = document.createElement('div');
|
||
it.className = 'gm-item' + (i === ghost.sel ? ' sel' : '') + (c.near ? ' near' : '');
|
||
if(c.fit) it.title = `echoes \u201c${c.fit}\u201d in your draft`;
|
||
const dot = c.color != null
|
||
? `<span class="gm-dot" style="background:var(--r${c.color})"></span>` : '';
|
||
it.innerHTML = `<span>${esc(c.word)}${c.fit ? '<span class="gm-fit"> \u2726</span>' : ''}</span>` +
|
||
`<span class="gm-syl">${'\u00b7'.repeat(Math.min(c.syl, 6))}${dot}</span>`;
|
||
it.addEventListener('mousedown', e=>e.preventDefault()); // keep editor focus
|
||
it.addEventListener('click', ()=>acceptGhost(c.word));
|
||
it.addEventListener('mouseenter', ()=>{ ghost.sel = i; ghost.text = c.word; render(); paintGhostMenu(); });
|
||
ghostMenu.appendChild(it);
|
||
});
|
||
editorShell.appendChild(ghostMenu);
|
||
positionGhostMenu();
|
||
}
|
||
function paintGhostMenu(){
|
||
if(!ghostMenu) return;
|
||
[...ghostMenu.children].forEach((el, i)=>el.classList.toggle('sel', i === ghost.sel));
|
||
}
|
||
function positionGhostMenu(){
|
||
if(!ghostMenu) return;
|
||
const g = highlight.querySelector('.ghost');
|
||
if(!g){ closeGhostMenu(); return; }
|
||
const sr = editorShell.getBoundingClientRect(), gr = g.getBoundingClientRect();
|
||
let left = gr.left - sr.left, top = gr.bottom - sr.top + 6;
|
||
left = Math.min(left, sr.width - ghostMenu.offsetWidth - 10);
|
||
if(top + ghostMenu.offsetHeight > sr.height - 8)
|
||
top = gr.top - sr.top - ghostMenu.offsetHeight - 6; // flip above
|
||
ghostMenu.style.left = Math.max(8, left) + 'px';
|
||
ghostMenu.style.top = Math.max(8, top) + 'px';
|
||
}
|
||
const ghostSoon = debounce(computeGhost, 150);
|
||
|
||
editor.addEventListener('keydown', e=>{
|
||
if(ghostMenu && ghost){
|
||
if(e.key === 'ArrowDown' || e.key === 'ArrowUp'){
|
||
e.preventDefault();
|
||
const n = ghost.cands.length;
|
||
ghost.sel = (ghost.sel + (e.key === 'ArrowDown' ? 1 : n - 1)) % n;
|
||
ghost.text = ghost.cands[ghost.sel].word;
|
||
render(); paintGhostMenu(); positionGhostMenu();
|
||
return;
|
||
}
|
||
if(e.key === 'Enter' || e.key === 'Tab'){
|
||
e.preventDefault(); acceptGhost(ghost.cands[ghost.sel].word); return;
|
||
}
|
||
if(e.key === 'Escape'){ e.preventDefault(); closeGhostMenu(); return; }
|
||
closeGhostMenu(); // any other key: back to writing
|
||
return;
|
||
}
|
||
if(e.key === 'Tab' && ghost){
|
||
const {ln, col} = caretLineCol();
|
||
const line = editor.value.split('\n')[ln] || '';
|
||
if(ln === ghost.line && col === line.length && line === ghost.base){
|
||
e.preventDefault();
|
||
openGhostMenu();
|
||
return;
|
||
}
|
||
}
|
||
if(e.key === 'Tab' && !ghost && suggestToggle.checked && !editor.readOnly){
|
||
// no ghost on screen? Tab asks for one — consent works both ways
|
||
const {ln, col} = caretLineCol();
|
||
const line = editor.value.split('\n')[ln] || '';
|
||
if(col === line.length && line.trim() && !/^\s*[#\[]/.test(line)){
|
||
e.preventDefault();
|
||
ghostDismissed = '';
|
||
forceGhost = true;
|
||
computeGhost().then(()=>{ forceGhost = false; if(ghost) openGhostMenu(); });
|
||
return;
|
||
}
|
||
}
|
||
if(e.key === 'Enter' && ghost && !ghostMenu){
|
||
ghostPassed.add(ghost.text); // walked past it — don't re-offer
|
||
}
|
||
if(e.key === 'Escape' && ghost){
|
||
ghostPassed.add(ghost.text);
|
||
ghostDismissed = ghost.line + '|' + (ghost.target || '');
|
||
ghost = null; render();
|
||
}
|
||
});
|
||
editor.addEventListener('input', ()=>{ lastType = Date.now(); });
|
||
editor.addEventListener('scroll', closeGhostMenu);
|
||
editor.addEventListener('blur', ()=>setTimeout(closeGhostMenu, 150));
|
||
|
||
/* ---------- double-click (or touch-select) a word -> look it up ---------- */
|
||
function lookupSelection(){
|
||
const sel = editor.value.slice(editor.selectionStart, editor.selectionEnd)
|
||
.trim().replace(/[^A-Za-z']/g,'');
|
||
if(sel && !sel.includes(' ')){
|
||
tab('lookup');
|
||
lookupFor(sel);
|
||
}
|
||
}
|
||
editor.addEventListener('dblclick', lookupSelection);
|
||
if(window.matchMedia('(pointer: coarse)').matches){
|
||
const _lh = document.getElementById('lookupHint');
|
||
if(_lh) _lh.innerHTML = 'tip: <b>select any word</b> in your draft to look it up here';
|
||
// no double-click on touch — long-press word selection does the job
|
||
editor.addEventListener('select', debounce(()=>{
|
||
const len = editor.selectionEnd - editor.selectionStart;
|
||
if(len > 0 && len <= 30) lookupSelection();
|
||
}, 400));
|
||
}
|
||
|
||
/* ---------- insert at cursor ---------- */
|
||
/* ============================================================
|
||
WORD ENTRIES — one lookup renders the whole entry: phonetic
|
||
readout, describes, rhymes (+near), synonyms. All of it comes
|
||
from our engine — no third-party calls.
|
||
============================================================ */
|
||
const lookupInput = document.getElementById('lookupInput');
|
||
const resultsBox = document.getElementById('lookupResults');
|
||
const lookupScroll = document.getElementById('lookupScroll');
|
||
const histBar = document.getElementById('histBar');
|
||
const defBox = document.getElementById('defBox');
|
||
let curWord = null, lookupSeq = 0, entry = null;
|
||
let subMode = 'explore';
|
||
const subSeg = document.getElementById('subSeg');
|
||
subSeg.addEventListener('click', e=>{
|
||
const b = e.target.closest('button'); if(!b) return;
|
||
subMode = b.dataset.sub;
|
||
[...subSeg.children].forEach(c=>c.classList.toggle('active', c.dataset.sub === subMode));
|
||
paintSections();
|
||
});
|
||
const wordHistory = [];
|
||
|
||
lookupInput.addEventListener('focus', ()=> lookupInput.select());
|
||
lookupInput.addEventListener('keydown', e=>{ if(e.key === 'Enter') doLookup(); });
|
||
lookupInput.addEventListener('input', debounce(()=>{
|
||
const w = lookupInput.value.trim();
|
||
if(w.length >= 2 && /^[A-Za-z' -]+\??$/.test(w)) doLookup();
|
||
}, 400));
|
||
|
||
function lookupFor(word){
|
||
const hint = document.getElementById('lookupHint');
|
||
if(hint) hint.remove(); // the tip has done its job
|
||
lookupInput.value = word;
|
||
doLookup();
|
||
}
|
||
|
||
function renderHistory(){
|
||
const past = wordHistory.filter(w=>w !== curWord).slice(-5).reverse();
|
||
histBar.innerHTML = past.length
|
||
? '↩ ' + past.map(w=>`<span class="hist" data-w="${esc(w)}">${esc(w)}</span>`).join(' · ')
|
||
: '';
|
||
histBar.querySelectorAll('.hist').forEach(el=>
|
||
el.addEventListener('click', ()=> lookupFor(el.dataset.w)));
|
||
}
|
||
|
||
async function doLookup(){
|
||
const word = lookupInput.value.trim().replace(/\?$/, '').toLowerCase();
|
||
if(!word || word === curWord) return;
|
||
lookupScroll.scrollTop = 0;
|
||
if(curWord){
|
||
const i = wordHistory.indexOf(curWord);
|
||
if(i >= 0) wordHistory.splice(i, 1);
|
||
wordHistory.push(curWord);
|
||
if(wordHistory.length > 8) wordHistory.shift();
|
||
}
|
||
curWord = word;
|
||
renderHistory();
|
||
const seq = ++lookupSeq;
|
||
defBox.innerHTML = `<div class="defhead"><b>${esc(word)}</b></div>` +
|
||
`<div class="muted defphon" id="defPhon">…</div>`;
|
||
resultsBox.innerHTML = '<p class="muted">gathering…</p>';
|
||
// rhymes go draft-aware when there's a draft to be aware of
|
||
const rhymeReq = editor.value.trim()
|
||
? fetch('/api/suggest', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({word, text: editor.value})})
|
||
: fetch(`/api/lookup?word=${encodeURIComponent(word)}&mode=rhyme`);
|
||
const [info, rhyme, syn, desc, trig] = await Promise.all([
|
||
fetch(`/api/word?word=${encodeURIComponent(word)}`).then(r=>r.json()).catch(()=>null),
|
||
rhymeReq.then(r=>r.json()).catch(()=>null),
|
||
fetch(`/api/lookup?word=${encodeURIComponent(word)}&mode=syn`).then(r=>r.json()).catch(()=>null),
|
||
fetch(`/api/lookup?word=${encodeURIComponent(word)}&mode=desc`).then(r=>r.json()).catch(()=>null),
|
||
fetch(`/api/lookup?word=${encodeURIComponent(word)}&mode=trig`).then(r=>r.json()).catch(()=>null),
|
||
]);
|
||
if(seq !== lookupSeq) return;
|
||
const el = defBox.querySelector('#defPhon');
|
||
if(el){
|
||
if(info && info.known){
|
||
const dots = [...info.stress].map(s=>s==='1' ? '●' : '○').join('');
|
||
// the rime is a suffix of the phones — color it in place
|
||
const ph = info.phones.toLowerCase(), rime = (info.rime || '').toLowerCase();
|
||
const phHtml = rime && ph.endsWith(rime)
|
||
? esc(ph.slice(0, ph.length - rime.length)) + `<i>${esc(rime)}</i>`
|
||
: esc(ph);
|
||
el.innerHTML = `/${phHtml}/ · ${info.syl} syl ${dots}` +
|
||
((info.homophones || []).length ? `<br>sounds like: ${info.homophones.map(esc).join(', ')}` : '');
|
||
}else{
|
||
el.textContent = 'not in the pronunciation dictionary';
|
||
}
|
||
}
|
||
const defs = (info && info.defs) || [];
|
||
if(defs.length){
|
||
const POS = {noun:'n.', verb:'v.', adj:'adj.', adv:'adv.', intj:'interj.',
|
||
prep:'prep.', conj:'conj.', pron:'pron.', det:'det.', num:'num.'};
|
||
const of = info.def_of ? `<div class="def"><i>→</i> ${esc(info.def_of)}</div>` : '';
|
||
defBox.insertAdjacentHTML('beforeend', '<div class="defs">' + of +
|
||
defs.map(d=>`<div class="def"><i>${POS[d.pos] || esc(d.pos)}</i> ${esc(d.gloss)}</div>`).join('') +
|
||
'</div>');
|
||
}
|
||
entry = {word, rhyme, syn, desc, trig};
|
||
paintSections();
|
||
}
|
||
|
||
function rarity(z){
|
||
if(z == null) return '';
|
||
return z >= 4.6 ? ' common' : (z < 3.4 ? ' rare' : '');
|
||
}
|
||
function chipHtml(items, cls){
|
||
// items: strings or {word, z, chime}; words already in the draft get
|
||
// a tick, words that also rhyme with the entry chime in gold
|
||
const inDraft = new Set((editor.value.toLowerCase().match(/[a-z']+/g)) || []);
|
||
return '<div class="results">' +
|
||
items.map(d=>{
|
||
const w = d.word || d, r = rarity(d.z);
|
||
const used = inDraft.has(w.toLowerCase()) ? ' indraft' : '';
|
||
const ch = d.chime === 'perfect' ? ' chime' : (d.chime === 'near' ? ' chime soft' : '');
|
||
const fit = d.fit ? ' fits' : '';
|
||
const tip = d.fit ? ` title="echoes “${esc(d.fit)}” in your draft"` : '';
|
||
return `<span class="chip${cls ? ' ' + cls : ''}${r}${used}${ch}${fit}" data-w="${esc(w)}"${tip}>${esc(w)}</span>`;
|
||
}).join('') + '</div>';
|
||
}
|
||
|
||
function paintSections(){
|
||
const e = entry;
|
||
if(!e) return;
|
||
let h = '';
|
||
if(subMode === 'explore'){
|
||
// the meaning side: what replaces it, what it summons, what describes it
|
||
if(e.syn && e.syn.known && e.syn.sections.length){
|
||
h += `<div class="res-label">synonyms</div>`;
|
||
e.syn.sections.forEach(s=>{
|
||
if(s.label !== 'synonyms') h += `<div class="res-label sub">${esc(s.label)}</div>`;
|
||
h += chipHtml(s.words, s.label === 'synonyms' ? '' : 'near');
|
||
});
|
||
}
|
||
if(e.trig && e.trig.known && e.trig.words.length){
|
||
h += `<div class="res-label">associations</div>` + chipHtml(e.trig.words);
|
||
}
|
||
if(e.desc && e.desc.known && e.desc.words.length){
|
||
h += `<div class="res-label">describes</div>` + chipHtml(e.desc.words);
|
||
}
|
||
}else{
|
||
// the sound side: rhymes, near, multis
|
||
if(e.rhyme && e.rhyme.known && (e.rhyme.words.length || (e.rhyme.near || []).length)){
|
||
const bySyl = {};
|
||
e.rhyme.words.forEach(d=>{ (bySyl[d.syl || 0] ||= []).push(d); });
|
||
const onWord = e.rhyme.rhyme_on ? ` — on “${esc(e.rhyme.rhyme_on)}”` : '';
|
||
h += `<div class="res-label">rhymes${onWord}</div>`;
|
||
Object.keys(bySyl).sort((a,b)=>a-b).forEach(k=>{
|
||
h += `<div class="res-label sub">${k == 0 ? '?' : k} syl</div>` + chipHtml(bySyl[k]);
|
||
});
|
||
const near = e.rhyme.near || [];
|
||
if(near.length) h += `<div class="res-label sub">near</div>` + chipHtml(near, 'near');
|
||
const multis = e.rhyme.multis || [];
|
||
if(multis.length) h += `<div class="res-label sub">multis</div>` + chipHtml(multis);
|
||
}
|
||
}
|
||
resultsBox.innerHTML = h || `<p class="muted">nothing here for “${esc(e.word)}”.</p>`;
|
||
lookupScroll.scrollTop = 0;
|
||
resultsBox.querySelectorAll('.chip').forEach(c=>
|
||
c.addEventListener('click', ()=> lookupFor(c.dataset.w)));
|
||
}
|
||
|
||
/* ============================================================
|
||
EXPORT IMAGE — draw the draft with its rhyme colors to a PNG,
|
||
entirely client-side.
|
||
============================================================ */
|
||
document.getElementById('exportBtn').addEventListener('click', async ()=>{
|
||
if(!editor.value.trim()) return;
|
||
await document.fonts.ready;
|
||
const lines = editor.value.split('\n');
|
||
const css = getComputedStyle(document.documentElement);
|
||
const palette = Array.from({length: COLORS}, (_, i)=>css.getPropertyValue(`--r${i}`).trim());
|
||
const ink = css.getPropertyValue('--ink').trim();
|
||
const bg = css.getPropertyValue('--bg').trim();
|
||
const rhythm = stressToggle.checked && analysis && analysis.stress;
|
||
const S = 2, FS = 16, LH = FS * (rhythm ? 2.35 : 1.9), PAD = 40;
|
||
const font = FS + "px 'Spline Sans Mono', monospace";
|
||
|
||
const probe = document.createElement('canvas').getContext('2d');
|
||
probe.font = font;
|
||
const w = Math.ceil(Math.max(220, ...lines.map(l=>probe.measureText(l).width)) + PAD * 2);
|
||
const h = Math.ceil(lines.length * LH + PAD * 2 + 18);
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = w * S; canvas.height = h * S;
|
||
const x = canvas.getContext('2d');
|
||
x.scale(S, S);
|
||
x.fillStyle = bg; x.fillRect(0, 0, w, h);
|
||
x.font = font; x.textBaseline = 'middle';
|
||
|
||
const hexRgb = hex =>{ hex = hex.replace('#',''); if(hex.length===3) hex=hex.split('').map(c=>c+c).join('');
|
||
return [parseInt(hex.slice(0,2),16),parseInt(hex.slice(2,4),16),parseInt(hex.slice(4,6),16)]; };
|
||
const inkRgb = hexRgb(ink), dimRgb = hexRgb(css.getPropertyValue('--ink-dim').trim());
|
||
const mix = (a,b,t)=>`rgb(${a.map((v,i)=>Math.round(v+(b[i]-v)*t)).join(',')})`;
|
||
const groupInfo = {}, tokByLine = {}, openByLine = {}, nearByLine = {};
|
||
if(analysis){
|
||
analysis.groups.forEach(g=>{ groupInfo[g.id] = g; });
|
||
analysis.tokens.forEach(t=>{ (tokByLine[t.l] ||= []).push(t); });
|
||
if(analysis.open) analysis.open.forEach(t=>{ (openByLine[t.l] ||= []).push(t); });
|
||
if(analysis.near) analysis.near.forEach(t=>{ (nearByLine[t.l] ||= []).push(t); });
|
||
}
|
||
const xat = (line, c)=> PAD + x.measureText(line.slice(0, c)).width;
|
||
lines.forEach((line, i)=>{
|
||
const y = PAD + i * LH + LH / 2;
|
||
const fresh = analysis && analysis.lines[i] === line;
|
||
const on = rhymeToggle.checked && fresh;
|
||
const toks = (on ? (tokByLine[i] || []) : []).filter(t=>groupInfo[t.g]);
|
||
const words = toks.filter(t=>!t.ph), phrases = toks.filter(t=>t.ph);
|
||
const nears = on ? (nearByLine[i] || []) : [];
|
||
const palHex = t => palette[groupInfo[t.g].color % COLORS];
|
||
|
||
// 1) fills — one rounded rect per token, opacity scaled by strength
|
||
const fillTok = (t, end0)=>{
|
||
let base = !t.ph ? (t.end ? 34 : 19) : (t.end ? 24 : 14);
|
||
const str = t.str != null ? t.str : (groupInfo[t.g].strength || 1);
|
||
x.globalAlpha = (base * (0.4 + 0.6*str)) / 100;
|
||
x.fillStyle = palHex(t);
|
||
x.beginPath();
|
||
x.roundRect(xat(line,t.s) - 2, y - FS*0.72, xat(line,t.e)-xat(line,t.s) + 4, FS*1.42, 4);
|
||
x.fill(); x.globalAlpha = 1;
|
||
};
|
||
phrases.forEach(t=>fillTok(t)); words.forEach(t=>fillTok(t));
|
||
|
||
// 2) base text (ink, or dim for headers/annotations)
|
||
const isHdr = /^\s*#/.test(line), isAnno = /^\s*\[/.test(line);
|
||
x.font = isHdr ? '700 ' + font : font;
|
||
x.fillStyle = isHdr ? '#8a7d6c' : isAnno ? '#6a5f52' : ink;
|
||
x.fillText(line, PAD, y);
|
||
x.font = font;
|
||
|
||
// 3) tint each rhyming word in its family color, over the ink
|
||
words.forEach(t=>{
|
||
const str = t.str != null ? t.str : (groupInfo[t.g].strength || 1);
|
||
x.fillStyle = mix(inkRgb, hexRgb(palHex(t)), 0.28 + 0.32*str);
|
||
x.fillText(line.slice(t.s, t.e), xat(line,t.s), y);
|
||
});
|
||
|
||
// 4) sub-word tail underline (rs..e) per word
|
||
words.forEach(t=>{
|
||
if(t.rs == null) return;
|
||
const str = t.str != null ? t.str : 1;
|
||
x.globalAlpha = 0.42 * str; x.fillStyle = palHex(t);
|
||
x.fillRect(xat(line,t.rs), y + FS*0.62, xat(line,t.e)-xat(line,t.rs), 1.5);
|
||
x.globalAlpha = 1;
|
||
});
|
||
// alliteration underline
|
||
if(allitToggle.checked && fresh){
|
||
(analysis.allit || []).filter(t=>t.l===i).forEach(t=>{
|
||
x.globalAlpha = 0.75; x.fillStyle = palette[t.g % COLORS];
|
||
x.fillRect(xat(line,t.s) - 1, y + FS*0.66, xat(line,t.e)-xat(line,t.s) + 2, 2);
|
||
x.globalAlpha = 1;
|
||
});
|
||
}
|
||
// near-miss: dotted gold underline
|
||
nears.forEach(t=>{
|
||
x.fillStyle = mix(inkRgb, hexRgb(css.getPropertyValue('--accent-2').trim()), 0.6);
|
||
let px = xat(line,t.s), end = xat(line,t.e);
|
||
while(px < end){ x.fillRect(px, y + FS*0.62, 1.6, 1.6); px += 3.5; }
|
||
});
|
||
|
||
if(rhythm && fresh){
|
||
const spans = (analysis.stress.filter(s=>s.l===i)).sort((a,b)=>a.s-b.s);
|
||
x.font = "8px 'Spline Sans Mono', monospace";
|
||
x.textAlign = 'center';
|
||
spans.forEach(s=>{
|
||
// measure with the body font for positions
|
||
x.font = font;
|
||
const left = PAD + x.measureText(line.slice(0, s.s)).width;
|
||
const wpx = x.measureText(line.slice(s.s, s.e)).width;
|
||
x.font = "9px 'Spline Sans Mono', monospace";
|
||
const dots = [...s.st].map(c=> c === '0' ? '○' : '●').join(' ');
|
||
x.fillText(dots, left + wpx / 2, y + FS * 0.95);
|
||
});
|
||
x.textAlign = 'left';
|
||
x.font = font;
|
||
}
|
||
});
|
||
x.fillStyle = 'rgba(167,154,137,0.55)';
|
||
x.font = "11px 'Spline Sans Mono', monospace";
|
||
x.textAlign = 'right';
|
||
x.fillText('rhymepad.org', w - 16, h - 16);
|
||
|
||
canvas.toBlob(blob=>{
|
||
const doc = docsState.docs.find(d=>d.id===docsState.current);
|
||
const name = ((doc && doc.title && doc.title !== 'Untitled') ? doc.title : 'rhymepad')
|
||
.replace(/[^\w\- ]+/g, '').trim() || 'rhymepad';
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = name + '.png';
|
||
a.click();
|
||
setTimeout(()=>URL.revokeObjectURL(a.href), 5000);
|
||
flash('exportBtn', 'Exported \u2713');
|
||
});
|
||
});
|
||
|
||
/* ============================================================
|
||
SMART PASTE — typographic quotes break phonetic tokenization
|
||
(don’t -> don + t), and lyrics sites ship cruft lines. Pasted
|
||
text gets normalized and de-crufted on the way in.
|
||
============================================================ */
|
||
const SECTION_RE = /^\[[^\]]{1,48}\]$/;
|
||
function extractLyricBlock(t){
|
||
// a whole-page Genius dump: lyrics live between the first [Section]
|
||
// header and the Embed/About/Comments footer
|
||
const lines = t.split('\n');
|
||
const first = lines.findIndex(l=>SECTION_RE.test(l.trim()));
|
||
if(first < 0) return null;
|
||
let end = -1;
|
||
for(let i = first; i < lines.length; i++){
|
||
const s = lines[i].trim();
|
||
if(/^\d*\s*Embed$/.test(s) || /^About$/.test(s) || /^Comments$/.test(s)){ end = i; break; }
|
||
}
|
||
const head = lines.slice(0, first).join('\n');
|
||
const pageish = /Contributors|Translations|Lyrics/i.test(head);
|
||
if(end < 0 && !pageish) return null; // just lyrics with [tags] — leave alone
|
||
let block = lines.slice(first, end < 0 ? lines.length : end);
|
||
// recommendation inserts run from "You might also like" to the next [Section]
|
||
const out = [];
|
||
let skipping = false;
|
||
for(const l of block){
|
||
const s = l.trim();
|
||
if(/^You might also like/i.test(s)){ skipping = true; continue; }
|
||
if(skipping){
|
||
if(SECTION_RE.test(s)) skipping = false;
|
||
else continue;
|
||
}
|
||
out.push(l);
|
||
}
|
||
while(out.length && /^\d*$/.test(out[out.length - 1].trim())) out.pop();
|
||
return out.join('\n').trim() + '\n';
|
||
}
|
||
function extractAZBlock(t){
|
||
// AZLyrics-style page: quoted-title headers up top, a "Thanks to…"
|
||
// credits footer below — the lyrics are what's between
|
||
const lines = t.split('\n');
|
||
const headRe = [/^AZLyrics/i, /^"[^"]+" lyrics$/i, /^.{1,60} Lyrics$/, /^"[^"]+"$/];
|
||
let start = -1;
|
||
for(let i = 0; i < Math.min(lines.length, 10); i++){
|
||
const s = lines[i].trim();
|
||
if(s && headRe.some(r=>r.test(s))) start = i;
|
||
}
|
||
if(start < 0) return null;
|
||
const endRe = [/^Thanks to .+ for (adding|correcting)/i, /^Writer\(s\):/i,
|
||
/^Lyrics licensed by/i, /^You May Also Like$/i, /^album:/i,
|
||
/^Submit Lyrics$/i];
|
||
let end = -1;
|
||
for(let i = start + 1; i < lines.length; i++){
|
||
if(endRe.some(r=>r.test(lines[i].trim()))){ end = i; break; }
|
||
}
|
||
if(end < 0) return null; // no footer evidence — not a page dump
|
||
const block = lines.slice(start + 1, end).join('\n').trim();
|
||
return block ? block + '\n' : null;
|
||
}
|
||
function extractGoogleBlock(t){
|
||
// Google's knowledge panel: a standalone "Lyrics" header, the verse,
|
||
// then a Source:/Songwriters: footer — search-result noise follows
|
||
const lines = t.split('\n');
|
||
let start = -1;
|
||
for(let i = 0; i < lines.length; i++){
|
||
if(lines[i].trim() === 'Lyrics'){ start = i; break; }
|
||
}
|
||
if(start < 0) return null;
|
||
while(start + 1 < lines.length &&
|
||
(lines[start + 1].trim() === 'Lyrics' || !lines[start + 1].trim())) start++;
|
||
let end = -1;
|
||
for(let i = start + 1; i < lines.length; i++){
|
||
if(/^(Source:\s|Songwriters?:)/i.test(lines[i].trim())){ end = i; break; }
|
||
}
|
||
if(end < 0) return null;
|
||
const block = lines.slice(start + 1, end).join('\n').trim();
|
||
return block ? block + '\n' : null;
|
||
}
|
||
function cleanPaste(t){
|
||
t = t.replace(/\r\n?/g, '\n')
|
||
.replace(/[\u2028\u2029\u0085\u000B\u000C]/g, '\n') // Google's lyrics box
|
||
.replace(/[\u2018\u2019\u02BC]/g, "'")
|
||
.replace(/[\u201C\u201D]/g, '"')
|
||
.replace(/\u00A0/g, ' ');
|
||
const extracted = extractLyricBlock(t) || extractGoogleBlock(t) || extractAZBlock(t);
|
||
if(extracted) return extracted;
|
||
const out = [];
|
||
t.split('\n').forEach((l, i)=>{
|
||
const s = l.trim();
|
||
if(i < 4 && /^\d+\s*Contributors/i.test(s)) return;
|
||
if(i < 4 && /Lyrics$/.test(s) && s.length < 60) return;
|
||
if(/^Translations$/i.test(s)) return;
|
||
if(/^You might also like/i.test(s)) return;
|
||
if(/^Source:\s/i.test(s)) return;
|
||
if(/^Songwriters?:/i.test(s)) return;
|
||
if(/^Musixmatch$/i.test(s)) return;
|
||
if(/^\d*\s*Embed$/.test(s)) return;
|
||
out.push(l);
|
||
});
|
||
return out.join('\n');
|
||
}
|
||
editor.addEventListener('paste', e=>{
|
||
const text = e.clipboardData && e.clipboardData.getData('text/plain');
|
||
if(!text) return;
|
||
const cleaned = cleanPaste(text);
|
||
if(cleaned === text) return;
|
||
e.preventDefault();
|
||
// execCommand keeps the native undo stack intact (setRangeText doesn't)
|
||
editor.focus();
|
||
if(!document.execCommand('insertText', false, cleaned)){
|
||
editor.setRangeText(cleaned, editor.selectionStart, editor.selectionEnd, 'end');
|
||
}
|
||
render(); analyzeSoon();
|
||
});
|
||
lookupInput.addEventListener('paste', e=>{
|
||
const text = e.clipboardData && e.clipboardData.getData('text/plain');
|
||
if(text && /[\u2018\u2019]/.test(text)){
|
||
e.preventDefault();
|
||
lookupInput.focus();
|
||
const fixed = text.replace(/[\u2018\u2019]/g, "'");
|
||
if(!document.execCommand('insertText', false, fixed)){
|
||
lookupInput.setRangeText(fixed, lookupInput.selectionStart, lookupInput.selectionEnd, 'end');
|
||
}
|
||
}
|
||
});
|
||
|
||
/* ============================================================
|
||
FILES IN, FILES OUT — drafts shouldn't be hostage to localStorage
|
||
============================================================ */
|
||
// ---- share links: the draft itself, gzipped into the URL fragment ----
|
||
async function gzB64(str){
|
||
const cs = new CompressionStream('gzip');
|
||
const blob = new Blob([new TextEncoder().encode(str)]);
|
||
const buf = await new Response(blob.stream().pipeThrough(cs)).arrayBuffer();
|
||
return btoa(String.fromCharCode(...new Uint8Array(buf)))
|
||
.replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
||
}
|
||
async function gunzB64(b64){
|
||
const bin = atob(b64.replace(/-/g,'+').replace(/_/g,'/'));
|
||
const bytes = Uint8Array.from(bin, c=>c.charCodeAt(0));
|
||
const ds = new DecompressionStream('gzip');
|
||
const buf = await new Response(new Blob([bytes]).stream().pipeThrough(ds)).arrayBuffer();
|
||
return new TextDecoder().decode(buf);
|
||
}
|
||
async function draftLink(){
|
||
const doc = docsState.docs.find(d=>d.id===docsState.current);
|
||
const payload = JSON.stringify({t: doc ? doc.title : 'Untitled', x: editor.value});
|
||
const enc = await gzB64(payload);
|
||
if(enc.length > 7000) return null; // past sane URL territory
|
||
return location.origin + '/?d=' + enc;
|
||
}
|
||
// the link is encoded eagerly in the background, so the click handlers
|
||
// stay synchronous — an await before share()/writeText() voids the
|
||
// user gesture and the browser refuses the share sheet
|
||
let linkCache = null;
|
||
const linkSoon = debounce(async ()=>{
|
||
linkCache = editor.value.trim() ? await draftLink() : null;
|
||
}, 400);
|
||
linkSoon();
|
||
|
||
const linkBtn = document.getElementById('linkBtn');
|
||
linkBtn.addEventListener('click', ()=>{
|
||
if(!editor.value.trim()) return;
|
||
if(!linkCache){ flash('linkBtn', 'Too long for a link'); return; }
|
||
navigator.clipboard.writeText(linkCache)
|
||
.then(()=>flash('linkBtn', 'Link copied \u2713'))
|
||
.catch(()=>flash('linkBtn', 'Copy failed'));
|
||
});
|
||
|
||
const shareBtn = document.getElementById('shareBtn');
|
||
if(navigator.share){
|
||
shareBtn.hidden = false;
|
||
shareBtn.addEventListener('click', ()=>{
|
||
if(!editor.value.trim()) return;
|
||
const doc = docsState.docs.find(d=>d.id===docsState.current);
|
||
navigator.share({
|
||
title: (doc && doc.title !== 'Untitled') ? doc.title : 'RhymePad draft',
|
||
text: editor.value,
|
||
...(linkCache ? {url: linkCache} : {}),
|
||
}).catch(()=>{ /* user closed the sheet */ });
|
||
});
|
||
}
|
||
|
||
// an incoming shared link (?d= — or #d= from older links) becomes a
|
||
// fresh draft, never clobbering yours
|
||
(async function importShared(){
|
||
const d = new URLSearchParams(location.search).get('d')
|
||
|| (location.hash.startsWith('#d=') ? location.hash.slice(3) : null);
|
||
if(!d) return;
|
||
try{
|
||
const {t, x} = JSON.parse(await gunzB64(d));
|
||
if(typeof x !== 'string' || !x.trim()) return;
|
||
newDoc();
|
||
const doc = docsState.docs.find(d=>d.id===docsState.current);
|
||
doc.title = (t && t !== 'Untitled') ? t : 'Shared draft';
|
||
editor.value = x;
|
||
localStorage.setItem(docKey(docsState.current), x);
|
||
saveDocs(); renderTabs();
|
||
render(); analyzeSoon();
|
||
}catch(e){ /* malformed link — ignore */ }
|
||
history.replaceState(null, '', location.pathname);
|
||
})();
|
||
// drop a .txt (or any text file) anywhere -> it becomes a new draft
|
||
document.addEventListener('dragover', e=>{ e.preventDefault(); });
|
||
document.addEventListener('drop', async e=>{
|
||
e.preventDefault();
|
||
const files = [...(e.dataTransfer?.files || [])].slice(0, 8);
|
||
for(const f of files){
|
||
if(f.size > 1024 * 1024) continue;
|
||
const text = cleanPaste(await f.text());
|
||
if(!text.trim()) continue;
|
||
const id = newId();
|
||
docsState.docs.push({id, title: titleOf(text)});
|
||
localStorage.setItem(docKey(id), text);
|
||
openDoc(id);
|
||
}
|
||
});
|
||
|
||
/* ============================================================
|
||
COPY / CLEAR / SAMPLE
|
||
============================================================ */
|
||
document.getElementById('copyBtn').addEventListener('click', async ()=>{
|
||
try{ await navigator.clipboard.writeText(editor.value); flash('copyBtn','Copied!'); }
|
||
catch{ editor.select(); document.execCommand('copy'); flash('copyBtn','Copied!'); }
|
||
});
|
||
function flash(id,msg){
|
||
const b=document.getElementById(id); const o=b.textContent; b.textContent=msg;
|
||
setTimeout(()=>b.textContent=o,1100);
|
||
}
|
||
|
||
const tab = (name)=>{
|
||
document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active', t.dataset.tab===name));
|
||
document.getElementById('tab-lookup').style.display = name==='lookup'?'flex':'none';
|
||
document.getElementById('tab-beats').style.display = name==='beats'?'block':'none';
|
||
};
|
||
document.querySelectorAll('.tab').forEach(t=> t.addEventListener('click', ()=>tab(t.dataset.tab)));
|
||
|
||
document.addEventListener('keydown', e=>{
|
||
if(!(e.metaKey || e.ctrlKey)) return;
|
||
if(e.key === 's'){
|
||
e.preventDefault(); // it's already saved — say so
|
||
const prev = schemeReadout.innerHTML;
|
||
schemeReadout.innerHTML = 'saved ✓';
|
||
setTimeout(buildReadout, 900);
|
||
}else if(e.key === 'k'){
|
||
e.preventDefault();
|
||
tab('lookup');
|
||
lookupInput.focus();
|
||
}
|
||
});
|
||
if(window.matchMedia('(pointer: fine)').matches) editor.focus();
|
||
|
||
render();
|
||
analyze();
|
||
|
||
|
||
export { editor, render, analysis, karaokeSpan, setKaraoke };
|